[med-svn] [Git][med-team/enlighten][upstream] New upstream version 1.6.0

Andreas Tille gitlab at salsa.debian.org
Mon Jul 13 13:40:45 BST 2020



Andreas Tille pushed to branch upstream at Debian Med / enlighten


Commits:
d1545d97 by Andreas Tille at 2020-07-13T14:09:30+02:00
New upstream version 1.6.0
- - - - -


27 changed files:

- + .github/ISSUE_TEMPLATE/bug_report.md
- + .github/ISSUE_TEMPLATE/feature_request.md
- README.rst
- doc/_static/demo.gif
- doc/api.rst
- doc/examples.rst
- doc/index.rst
- doc/patterns.rst
- enlighten/__init__.py
- + enlighten/_basecounter.py
- enlighten/_counter.py
- enlighten/_manager.py
- + enlighten/_statusbar.py
- + enlighten/_util.py
- enlighten/counter.py
- examples/demo.py
- examples/multicolored.py
- examples/multiple_logging.py
- pylintrc
- setup.py
- tests/__init__.py
- + tests/test_basecounter.py
- tests/test_counter.py
- tests/test_manager.py
- + tests/test_statusbar.py
- + tests/test_util.py
- tox.ini


Changes:

=====================================
.github/ISSUE_TEMPLATE/bug_report.md
=====================================
@@ -0,0 +1,23 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+It's best to provide a generic code sample that illustrates the problem. Examples from the documentation are a good starting point.
+
+**Environment (please complete the following information):**
+ - Enlighten Version:
+ - OS and version:
+ - Console application: [e.g. xterm, cmd, VS Code Terminal]
+ - Special Conditions: [e.g. Running under pyinstaller]
+
+**Additional context**
+Add any other context about the problem here.


=====================================
.github/ISSUE_TEMPLATE/feature_request.md
=====================================
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: Feature Request
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.


=====================================
README.rst
=====================================
@@ -64,6 +64,12 @@ The main advantage of Enlighten is it allows writing to stdout and stderr withou
 redirection.
 
 .. image:: https://raw.githubusercontent.com/Rockhopper-Technologies/enlighten/master/doc/_static/demo.gif
+    :target: http://python-enlighten.readthedocs.io/en/stable/examples.html
+
+The code for this animation can be found in
+`demo.py <https://github.com/Rockhopper-Technologies/enlighten/blob/master/examples/demo.py>`__
+in
+`examples <https://github.com/Rockhopper-Technologies/enlighten/tree/master/examples>`__.
 
 Documentation
 =============


=====================================
doc/_static/demo.gif
=====================================
Binary files a/doc/_static/demo.gif and b/doc/_static/demo.gif differ


=====================================
doc/api.rst
=====================================
@@ -24,6 +24,11 @@ Classes
     :inherited-members:
     :exclude-members: elapsed, position
 
+.. autoclass:: StatusBar
+    :members:
+    :inherited-members:
+    :exclude-members: elapsed, position
+
 .. autoclass:: SubCounter
     :members:
 
@@ -31,3 +36,9 @@ Functions
 ---------
 
 .. autofunction:: enlighten.get_manager(stream=None, counter_class=Counter, **kwargs)
+
+
+Constants
+---------
+
+.. autoclass:: enlighten.Justify
\ No newline at end of file


=====================================
doc/examples.rst
=====================================
@@ -72,11 +72,55 @@ total. If neither of these conditions are met, the counter format is used:
         time.sleep(0.1)  # Simulate work
         counter.update()
 
+Status Bars
+-----------
+Status bars are bars that work similarly to progress similarly to progress bars and counters,
+but present relatively static information.
+Status bars are created with :py:meth:`Manager.status_bar <enlighten.Manager.status_bar>`.
+
+.. code-block:: python
+
+    import enlighten
+    import time
+
+    manager = enlighten.get_manager()
+    status_bar = manager.status_bar('Static Message',
+                                    color='white_on_red',
+                                    justify=enlighten.Justify.CENTER)
+    time.sleep(1)
+    status_bar.update('Updated static message')
+    time.sleep(1)
+
+Status bars can also use formatting with dynamic variables.
+
+.. code-block:: python
+
+    import enlighten
+    import time
+
+    manager = enlighten.get_manager()
+    status_format = '{program}{fill}Stage: {stage}{fill} Status {status}'
+    status_bar = manager.status_bar(status_format=status_format,
+                                    color='bold_slategray',
+                                    program='Demo',
+                                    stage='Loading',
+                                    status='OKAY')
+    time.sleep(1)
+    status_bar.update(stage='Initializing', status='OKAY')
+    time.sleep(1)
+    status_bar.update(status='FAIL')
+
+Status bars, like other bars can be pinned. To pin a status bar to the top of all other bars,
+initialize it before any other bars. To pin a bar to the bottom of the screen, use
+``position=1`` when initializing.
+
+
 Color
 -----
 
-The bar component of a progress bar can be colored by setting the ``color`` keyword argument.
-See :ref:`Series Color <series_color>` for more information about valid colors.
+Status bars and the bar component of a progress bar can be colored by setting the
+``color`` keyword argument. See :ref:`Series Color <series_color>` for more information
+about valid colors.
 
 .. code-block:: python
 
@@ -128,12 +172,6 @@ of the underlying `Blessed <https://blessed.readthedocs.io/en/stable>`_
     bar_format = manager.term.red(u'{desc}') + u'{desc_pad}' + \
                  manager.term.blue(u'{percentage:3.0f}%') + u'|{bar}|'
 
-    # Change the background of only the bar
-    bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|' + \
-                 manager.term.on_white(u'{bar}') + \
-                 u'| {count:{len_total}d}/{total:d} ' + \
-                 u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]'
-
     # Apply to counter
     ticks = manager.counter(total=100, desc='Ticks', unit='ticks', bar_format=bar_format)
 


=====================================
doc/index.rst
=====================================
@@ -25,3 +25,9 @@ The main advantage of Enlighten is it allows writing to stdout and stderr withou
 redirection.
 
 .. image:: _static/demo.gif
+  :target: examples.html
+
+The code for this animation can be found in
+`demo.py <https://github.com/Rockhopper-Technologies/enlighten/blob/master/examples/demo.py>`__
+in
+`examples <https://github.com/Rockhopper-Technologies/enlighten/tree/master/examples>`__.
\ No newline at end of file


=====================================
doc/patterns.rst
=====================================
@@ -84,4 +84,35 @@ the iterables and then updates the count by 1.
 
     for sheep in pbar(flock1, flock2):
         time.sleep(0.2)
-        print('%s: Baaa' % sheep)
\ No newline at end of file
+        print('%s: Baaa' % sheep)
+
+User-defined fields
+-------------------
+
+Both :py:class:`~enlighten.Counter` and Both :py:class:`~enlighten.StatusBar` accept
+user defined fields as keyword arguments at initialization and during an update.
+These fields are persistent and only need to be specified when they change.
+
+In the following example, ``source`` is a user-defined field that is periodically updated.
+
+.. code-block:: python
+
+    import enlighten
+    import random
+    import time
+
+    bar_format = u'{desc}{desc_pad}{source} {percentage:3.0f}%|{bar}| ' + \
+                 u'{count:{len_total}d}/{total:d} ' + \
+                 u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]'
+    manager = enlighten.get_manager(bar_format=bar_format)
+
+    bar = manager.counter(total=100, desc='Loading', unit='files', source='server.a')
+    for num in range(100):
+        time.sleep(0.1)  # Simulate work
+        if not num % 5:
+            bar.update(source=random.choice(['server.a', 'server.b', 'server.c']))
+        else:
+            bar.update()
+
+For more information, see the :ref:`Counter Format <counter_format>` and
+:ref:`StatusBar Format <status_format>` sections.
\ No newline at end of file


=====================================
enlighten/__init__.py
=====================================
@@ -11,9 +11,10 @@
 Provides progress bars and counters which play nice in a TTY console
 """
 
-from enlighten.counter import Counter, SubCounter
+from enlighten.counter import Counter, StatusBar, SubCounter
 from enlighten._manager import Manager, get_manager
+from enlighten._util import Justify
 
 
-__version__ = '1.5.2'
-__all__ = ('Counter', 'Manager', 'SubCounter', 'get_manager')
+__version__ = '1.6.0'
+__all__ = ('Counter', 'Justify', 'Manager', 'StatusBar', 'SubCounter', 'get_manager')


=====================================
enlighten/_basecounter.py
=====================================
@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+**Enlighten base counter submodule**
+
+Provides BaseCounter and PrintableCounter classes
+"""
+
+import time
+
+from enlighten._util import BASESTRING
+
+try:
+    from collections.abc import Iterable
+except ImportError:  # pragma: no cover(Python 2)
+    from collections import Iterable
+
+
+class BaseCounter(object):
+    """
+    Args:
+        manager(:py:class:`Manager`): Manager instance. Required.
+        color(str): Color as a string or RGB tuple (Default: None)
+
+    Base class for counters
+    """
+
+    __slots__ = ('_color', 'count', 'manager', 'start_count')
+    _repr_attrs = ('count', 'color')
+
+    def __repr__(self):
+
+        params = []
+        for attr in self._repr_attrs:
+            value = getattr(self, attr)
+            if value is not None:
+                params.append('%s=%r' % (attr, value))
+
+        return '%s(%s)' % (self.__class__.__name__, ', '.join(params))
+
+    def __init__(self, **kwargs):
+
+        self.count = self.start_count = kwargs.get('count', 0)
+        self._color = None
+
+        self.manager = kwargs.get('manager', None)
+        if self.manager is None:
+            raise TypeError('manager must be specified')
+
+        self.color = kwargs.get('color', None)
+
+    @property
+    def color(self):
+        """
+        Color property
+
+        Preferred to be a string or iterable of three integers for RGB.
+        Single integer supported for backwards compatibility
+        """
+
+        color = self._color
+        return color if color is None else color[0]
+
+    @color.setter
+    def color(self, value):
+
+        if value is None:
+            self._color = None
+        elif isinstance(value, int) and 0 <= value <= 255:
+            self._color = (value, self.manager.term.color(value))
+        elif isinstance(value, BASESTRING):
+            term = self.manager.term
+            color_cap = self.manager.term.formatter(value)
+            if not color_cap and term.does_styling and term.number_of_colors:
+                raise AttributeError('Invalid color specified: %s' % value)
+            self._color = (value, color_cap)
+        elif isinstance(value, Iterable) and \
+                len(value) == 3 and \
+                all(isinstance(_, int) and 0 <= _ <= 255 for _ in value):
+            self._color = (value, self.manager.term.color_rgb(*value))
+        else:
+            raise AttributeError('Invalid color specified: %s' % repr(value))
+
+    def _colorize(self, content):
+        """
+        Args:
+            content(str): Color as a string or number 0 - 255 (Default: None)
+
+        Returns:
+            :py:class:`str`: content formatted with color
+
+        Format ``content`` with the color specified for this progress bar
+
+        If no color is specified for this instance, the content is returned unmodified
+        """
+
+        # No color specified
+        if self._color is None:
+            return content
+
+        # Used spec cached by color.setter
+        return self._color[1](content)
+
+    def update(self, *args, **kwargs):
+        """
+        Placeholder for update method
+        """
+
+        raise NotImplementedError
+
+    def __call__(self, *args):
+
+        for iterable in args:
+            if not isinstance(iterable, Iterable):
+                raise TypeError('Argument type %s is not iterable' % type(iterable).__name__)
+
+            for element in iterable:
+                yield element
+                self.update()
+
+
+class PrintableCounter(BaseCounter):
+    """
+    Base class for printable counters
+    """
+
+    __slots__ = ('enabled', '_fill', 'last_update', 'leave', 'min_delta', '_pinned', 'start')
+
+    def __init__(self, **kwargs):
+
+        super(PrintableCounter, self).__init__(**kwargs)
+
+        self.enabled = kwargs.get('enabled', True)
+        self._fill = u' '
+        self.fill = kwargs.get('fill', u' ')
+        self.leave = kwargs.get('leave', True)
+        self.min_delta = kwargs.get('min_delta', 0.1)
+        self._pinned = False
+        self.last_update = self.start = time.time()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, *args):
+        self.close()
+
+    @property
+    def elapsed(self):
+        """
+        Get elapsed time is seconds (float)
+        """
+
+        return time.time() - self.start
+
+    @property
+    def fill(self):
+        """
+        Fill character used in formatting
+        """
+        return self._fill
+
+    @fill.setter
+    def fill(self, value):
+
+        char_len = self.manager.term.length(value)
+        if char_len != 1:
+            raise ValueError('fill character must be a length of 1 '
+                             'when printed. Length: %d, Value given: %r' % (char_len, value))
+
+        self._fill = value
+
+    @property
+    def position(self):
+        """
+        Fetch position from the manager
+        """
+
+        return self.manager.counters.get(self, 0)
+
+    def clear(self, flush=True):
+        """
+        Args:
+            flush(bool): Flush stream after clearing bar (Default:True)
+
+        Clear bar
+        """
+
+        if self.enabled:
+            self.manager.write(flush=flush, counter=self)
+            self.last_update = 0
+
+    def close(self, clear=False):
+        """
+        Do final refresh and remove from manager
+
+        If ``leave`` is True, the default, the effect is the same as :py:meth:`refresh`.
+        """
+
+        if clear and not self.leave:
+            self.clear()
+        else:
+            self.refresh()
+
+        self.manager.remove(self)
+
+    def format(self, width=None, elapsed=None):
+        """
+        Format counter for printing
+        """
+
+        raise NotImplementedError
+
+    def refresh(self, flush=True, elapsed=None):
+        """
+        Args:
+            flush(bool): Flush stream after writing bar (Default:True)
+            elapsed(float): Time since started. Automatically determined if :py:data:`None`
+
+        Redraw bar
+        """
+
+        if self.enabled:
+            self.last_update = time.time()
+            self.manager.write(output=self.format(elapsed=elapsed),
+                               flush=flush, counter=self)
+
+    def _fill_text(self, text, width, offset=None):
+        """
+        Args:
+            text (str): String to modify
+            width (int): Width in columns to make progress bar
+            offset(int): Number of non-printable characters to account for when formatting
+
+        Returns:
+            :py:class:`str`: String with ``'{0}'`` replaced with fill characters
+
+        Replace ``'{0}'`` in string with appropriate number of fill characters
+        """
+
+        fill_count = text.count(u'{0}')
+        if not fill_count:
+            return text
+
+        if offset is None:
+            remaining = width - self.manager.term.length(text) + 3 * fill_count
+        else:
+            remaining = width - len(text) + offset + 3 * fill_count
+
+        if fill_count == 1:
+            return text.format(self.fill * remaining)
+
+        fill_size, extra = divmod(remaining, fill_count)
+
+        # Add extra fill evenly starting from the last one
+        text = '{1}'.join(text.rsplit('{0}', extra))
+        return text.format(self.fill * fill_size,
+                           self.fill * (fill_size + 1))


=====================================
enlighten/_counter.py
=====================================
@@ -8,19 +8,15 @@
 """
 **Enlighten counter submodule**
 
-Provides Counter base class
+Provides Counter and SubConter classes
 """
 
 import platform
 import sys
 import time
 
-try:
-    from collections.abc import Iterable
-except ImportError:  # pragma: no cover(Python 2)
-    from collections import Iterable
-
-from blessed.colorspace import X11_COLORNAMES_TO_RGB
+from enlighten._basecounter import BaseCounter, PrintableCounter
+from enlighten._util import format_time
 
 COUNTER_FMT = u'{desc}{desc_pad}{count:d} {unit}{unit_pad}' + \
               u'[{elapsed}, {rate:.2f}{unit_pad}{unit}/s]{fill}'
@@ -28,6 +24,8 @@ COUNTER_FMT = u'{desc}{desc_pad}{count:d} {unit}{unit_pad}' + \
 BAR_FMT = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| {count:{len_total}d}/{total:d} ' + \
           u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]'
 
+STATUS_FMT = u'{message}'
+
 # Even with cp65001, Windows doesn't seem to support all unicode characters
 if platform.system() == 'Windows':  # pragma: no cover(Windows)
     SERIES_STD = u' ▌█'
@@ -42,141 +40,6 @@ except UnicodeEncodeError:  # pragma: no cover(Non-unicode Terminal)
 except (AttributeError, TypeError):  # pragma: no cover(Non-standard Terminal)
     pass
 
-COLORS_16 = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
-             'bright_black', 'bright_red', 'bright_green', 'bright_yellow',
-             'bright_blue', 'bright_magenta', 'bright_cyan', 'bright_white')
-
-try:
-    BASESTRING = basestring
-except NameError:
-    BASESTRING = str
-
-
-def _format_time(seconds):
-    """
-    Args:
-        seconds (float): amount of time
-
-    Format time string for eta and elapsed
-    """
-
-    # Always do minutes and seconds in mm:ss format
-    minutes = seconds // 60
-    hours = minutes // 60
-    rtn = u'{0:02.0f}:{1:02.0f}'.format(minutes % 60, seconds % 60)
-
-    #  Add hours if there are any
-    if hours:
-
-        rtn = u'{0:d}h {1}'.format(int(hours % 24), rtn)
-
-        #  Add days if there are any
-        days = int(hours // 24)
-        if days:
-            rtn = u'{0:d}d {1}'.format(days, rtn)
-
-    return rtn
-
-
-class BaseCounter(object):
-    """
-    Args:
-        manager(:py:class:`Manager`): Manager instance. Required.
-        color(str): Color as a string or RGB tuple (Default: None)
-
-    Base class for counters
-    """
-
-    __slots__ = ('_color', 'count', 'manager', 'start_count')
-    _repr_attrs = ('count', 'color')
-
-    def __repr__(self):
-
-        params = []
-        for attr in self._repr_attrs:
-            value = getattr(self, attr)
-            if value is not None:
-                params.append('%s=%r' % (attr, value))
-
-        return '%s(%s)' % (self.__class__.__name__, ', '.join(params))
-
-    def __init__(self, **kwargs):
-
-        self.count = self.start_count = kwargs.get('count', 0)
-        self._color = None
-
-        self.manager = kwargs.get('manager', None)
-        if self.manager is None:
-            raise TypeError('manager must be specified')
-
-        self.color = kwargs.get('color', None)
-
-    @property
-    def color(self):
-        """
-        Color property
-        Preferred to be a string or iterable of three integers for RGB
-        Single integer supported for backwards compatibility
-        """
-
-        color = self._color
-        return color if color is None else color[0]
-
-    @color.setter
-    def color(self, value):
-
-        if value is None:
-            self._color = None
-        elif isinstance(value, int) and 0 <= value <= 255:
-            self._color = (value, self.manager.term.color(value))
-        elif isinstance(value, BASESTRING):
-            if value not in COLORS_16 or value not in X11_COLORNAMES_TO_RGB:
-                raise AttributeError('Invalid color specified: %s' % value)
-            self._color = (value, getattr(self.manager.term, value))
-        elif isinstance(value, Iterable) and \
-                len(value) == 3 and \
-                all(isinstance(_, int) and 0 <= _ <= 255 for _ in value):
-            self._color = (value, self.manager.term.color_rgb(*value))
-        else:
-            raise AttributeError('Invalid color specified: %s' % repr(value))
-
-    def _colorize(self, content):
-        """
-        Args:
-            content(str): Color as a string or number 0 - 255 (Default: None)
-
-        Returns:
-            :py:class:`str`: content formatted with color
-
-        Format ``content`` with the color specified for this progress bar
-
-        If no color is specified for this instance, the content is returned unmodified
-        """
-
-        # No color specified
-        if self._color is None:
-            return content
-
-        # Used spec cached by color.setter
-        return self._color[1](content)
-
-    def update(self, incr=1, force=False):
-        """
-        Placeholder for update method
-        """
-
-        raise NotImplementedError
-
-    def __call__(self, *args):
-
-        for iterable in args:
-            if not isinstance(iterable, Iterable):
-                raise TypeError('Argument type %s is not iterable' % type(iterable).__name__)
-
-            for element in iterable:
-                yield element
-                self.update()
-
 
 class SubCounter(BaseCounter):
     """
@@ -216,7 +79,7 @@ class SubCounter(BaseCounter):
         self.parent = parent
         self.all_fields = all_fields
 
-    def update(self, incr=1, force=False):
+    def update(self, incr=1, force=False):  # pylint: disable=arguments-differ
         """
         Args:
             incr(int): Amount to increment ``count`` (Default: 1)
@@ -267,20 +130,23 @@ class SubCounter(BaseCounter):
             raise ValueError('source must be parent or peer')
 
 
-class Counter(BaseCounter):
+class Counter(PrintableCounter):
     """
     .. spelling::
         desc
         len
+        seagreen
+        peru
 
     Args:
-        additional_fields(dict): Additional fields used for :ref:`formating <counter_format>`
         bar_format(str): Progress bar format, see :ref:`Format <counter_format>` below
         count(int): Initial count (Default: 0)
         counter_format(str): Counter format, see :ref:`Format <counter_format>` below
         color(str): Series color as a string or RGB tuple see :ref:`Series Color <series_color>`
         desc(str): Description
         enabled(bool): Status (Default: :py:data:`True`)
+        fill(str): Fill character used for ``counter_format`` (Default: ' ')
+        fields(dict): Additional fields used for :ref:`formatting <counter_format>`
         leave(True): Leave progress bar after closing (Default: :py:data:`True`)
         manager(:py:class:`Manager`): Manager instance. Creates instance if not specified.
         min_delta(float): Minimum time, in seconds, between refreshes (Default: 0.1)
@@ -350,6 +216,9 @@ class Counter(BaseCounter):
         For backward compatibility, a color can be expressed as an integer 0 - 255, but this
         is deprecated in favor of named or RGB colors.
 
+        Compound colors, such as 'white_on_seagreen', 'bold_red', or 'underline_on_peru' are
+        also supported.
+
         If a terminal is not capable of 24-bit color, and is given a color outside of its
         range, the color will be downconverted to a supported color.
 
@@ -427,9 +296,10 @@ class Counter(BaseCounter):
         - percentage(:py:class:`float`) - Percentage complete
         - total(:py:class:`int`) - Value of ``total``
 
-        Addition fields for ``counter_format`` only:
+        Additional fields for ``counter_format`` only:
 
-        - fill(:py:class:`str`) - blank spaces, number needed to fill line
+        - fill(:py:class:`str`) - Filled with :py:attr:`fill` until line is width of terminal.
+          May be used multiple times. Minimum width is 3.
 
         Additional fields when subcounters are used:
 
@@ -453,17 +323,22 @@ class Counter(BaseCounter):
 
         User-defined fields:
 
-            The ``additional_fields`` parameter can be used to pass a dictionary of additional
+            Users can define fields in two ways, the ``fields`` parameter and by passing keyword
+            arguments to :py:meth:`Manager.counter` or :py:meth:`Counter.update`
+
+            The ``fields`` parameter can be used to pass a dictionary of additional
             user-defined fields. The dictionary values can be updated after initialization to allow
             for dynamic fields. Any fields that share names with built-in fields are ignored.
 
+            If fields are passed as keyword arguments to :py:meth:`Manager.counter` or
+            :py:meth:`Counter.update`, they take precedent over the ``fields`` parameter.
 
     .. _counter_offset:
 
     **Offset**
 
         When ``offset`` is :py:data:`None`, the width of the bar portion of the progress bar and
-        the fill characters for counter will be automatically determined,
+        the fill size for counter will be automatically determined,
         taking into account terminal escape sequences that may be included in the string.
 
         Under special circumstances, and to permit backward compatibility, ``offset`` may be
@@ -509,9 +384,8 @@ class Counter(BaseCounter):
     """
     # pylint: disable=too-many-instance-attributes
 
-    __slots__ = ('additional_fields', 'bar_format', 'counter_format', 'desc', 'enabled',
-                 'last_update', 'leave', 'manager', 'min_delta', 'offset', 'series', 'start',
-                 'total', 'unit', '_subcounters')
+    __slots__ = ('bar_format', 'counter_format', 'desc', 'fields', 'manager',
+                 'offset', 'series', 'total', 'unit', '_fields', '_subcounters')
     _repr_attrs = ('desc', 'total', 'count', 'unit', 'color')
 
     # pylint: disable=too-many-arguments
@@ -519,35 +393,18 @@ class Counter(BaseCounter):
 
         super(Counter, self).__init__(**kwargs)
 
-        self.additional_fields = kwargs.get('additional_fields', {})
-        self.bar_format = kwargs.get('bar_format', BAR_FMT)
-        self.counter_format = kwargs.get('counter_format', COUNTER_FMT)
-        self.desc = kwargs.get('desc', None)
-        self.enabled = kwargs.get('enabled', True)
-        self.leave = kwargs.get('leave', True)
-        self.min_delta = kwargs.get('min_delta', 0.1)
-        self.offset = kwargs.get('offset', None)
-        self.series = kwargs.get('series', SERIES_STD)
-        self.total = kwargs.get('total', None)
-        self.unit = kwargs.get('unit', None)
+        # Accept additional_fields for backwards compatibility
+        self.fields = kwargs.pop('fields', kwargs.pop('additional_fields', {}))
+        self.bar_format = kwargs.pop('bar_format', BAR_FMT)
+        self.counter_format = kwargs.pop('counter_format', COUNTER_FMT)
+        self.desc = kwargs.pop('desc', None)
+        self.offset = kwargs.pop('offset', None)
+        self.series = kwargs.pop('series', SERIES_STD)
+        self.total = kwargs.pop('total', None)
+        self.unit = kwargs.pop('unit', None)
+        self._fields = kwargs
         self._subcounters = []
 
-        self.last_update = self.start = time.time()
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, *args):
-        self.close()
-
-    @property
-    def position(self):
-        """
-        Fetch position from the manager
-        """
-
-        return self.manager.counters.get(self, 0)
-
     @property
     def elapsed(self):
         """
@@ -570,31 +427,6 @@ class Counter(BaseCounter):
 
         return sum(subcounter.count for subcounter in self._subcounters)
 
-    def clear(self, flush=True):
-        """
-        Args:
-            flush(bool): Flush stream after clearing progress bar (Default:True)
-
-        Clear progress bar
-        """
-
-        if self.enabled:
-            self.manager.write(flush=flush, position=self.position)
-
-    def close(self, clear=False):
-        """
-        Do final refresh and remove from manager
-
-        If ``leave`` is True, the default, the effect is the same as :py:meth:`refresh`.
-        """
-
-        if clear and not self.leave:
-            self.clear()
-        else:
-            self.refresh()
-
-        self.manager.remove(self)
-
     def _get_subcounters(self, elapsed, bar_fields=True):
         """
         Args:
@@ -645,7 +477,7 @@ class Counter(BaseCounter):
                 if self.total == 0:
                     fields['eta_{0}'.format(num)] = u'00:00'
                 elif rate:
-                    fields['eta_{0}'.format(num)] = _format_time((self.total - interations) / rate)
+                    fields['eta_{0}'.format(num)] = format_time((self.total - interations) / rate)
                 else:
                     fields['eta_{0}'.format(num)] = u'?'
 
@@ -668,7 +500,8 @@ class Counter(BaseCounter):
 
         iterations = abs(self.count - self.start_count)
 
-        fields = self.additional_fields.copy()
+        fields = self.fields.copy()
+        fields.update(self._fields)
         fields.update({'bar': u'{0}',
                        'count': self.count,
                        'desc': self.desc or u'',
@@ -681,7 +514,7 @@ class Counter(BaseCounter):
         if elapsed is None:
             elapsed = self.elapsed
 
-        fields['elapsed'] = _format_time(elapsed)
+        fields['elapsed'] = format_time(elapsed)
 
         # Get rate. Elapsed could be 0 if counter was not updated and has a zero total.
         if elapsed:
@@ -707,7 +540,7 @@ class Counter(BaseCounter):
                 # Get eta
                 if fields['rate']:
                     # Use iterations so a counter running backwards is accurate
-                    fields['eta'] = _format_time((self.total - iterations) / fields['rate'])
+                    fields['eta'] = format_time((self.total - iterations) / fields['rate'])
                 else:
                     fields['eta'] = u'?'
 
@@ -726,8 +559,7 @@ class Counter(BaseCounter):
             try:
                 rtn = self.bar_format.format(**fields)
             except KeyError as e:
-                raise ValueError('%r specified in format, but not present in additional_fields' %
-                                 e.args[0])
+                raise ValueError('%r specified in format, but not provided' % e.args[0])
 
             # Format the bar
             if self.offset is None:
@@ -767,35 +599,16 @@ class Counter(BaseCounter):
         try:
             rtn = self.counter_format.format(**fields)
         except KeyError as e:
-            raise ValueError('%r specified in format, but not present in additional_fields' %
-                             e.args[0])
-
-        if self.offset is None:
-            ret = rtn.format(u' ' * (width - self.manager.term.length(rtn) + 3))
-        else:
-            # Offset was explicitly given
-            ret = rtn.format(u' ' * (width - len(rtn) + self.offset + 3))
+            raise ValueError('%r specified in format, but not provided' % e.args[0])
 
-        return ret
-
-    def refresh(self, flush=True, elapsed=None):
-        """
-        Args:
-            flush(bool): Flush stream after writing progress bar (Default:True)
-            elapsed(float): Time since started. Automatically determined if :py:data:`None`
-
-        Redraw progress bar
-        """
-
-        if self.enabled:
-            self.manager.write(output=self.format(elapsed=elapsed),
-                               flush=flush, position=self.position)
+        return self._fill_text(rtn, width, offset=self.offset)
 
-    def update(self, incr=1, force=False):
+    def update(self, incr=1, force=False, **fields):  # pylint: disable=arguments-differ
         """
         Args:
             incr(int): Amount to increment ``count`` (Default: 1)
             force(bool): Force refresh even if ``min_delta`` has not been reached
+            fields(dict): Fields for for :ref:`formatting <counter_format>`
 
         Increment progress bar and redraw
 
@@ -803,12 +616,12 @@ class Counter(BaseCounter):
         """
 
         self.count += incr
+        self._fields.update(fields)
         if self.enabled:
             currentTime = time.time()
             # Update if force, 100%, or minimum delta has been reached
             if force or self.count == self.total or \
                     currentTime - self.last_update >= self.min_delta:
-                self.last_update = currentTime
                 self.refresh(elapsed=currentTime - self.start)
 
     def add_subcounter(self, color, count=0, all_fields=False):


=====================================
enlighten/_manager.py
=====================================
@@ -23,6 +23,7 @@ except ImportError:  # pragma: no cover (Python 2.6)
 
 
 from enlighten._counter import Counter
+from enlighten._statusbar import StatusBar
 from enlighten._terminal import Terminal
 
 
@@ -72,9 +73,12 @@ class Manager(object):
 
         self.stream = sys.stdout if stream is None else stream
         self.counter_class = counter_class
+        self.status_bar_class = StatusBar
+
         self.counters = OrderedDict()
         self.enabled = kwargs.get('enabled', True)  # Double duty for counters
-        self.no_resize = kwargs.get('no_resize', False)
+        self.no_resize = kwargs.pop('no_resize', False)
+        self.set_scroll = kwargs.pop('set_scroll', True)
         self.term = Terminal(stream=self.stream)
 
         # Set up companion stream
@@ -99,12 +103,14 @@ class Manager(object):
         else:
             self.companion_term = None
 
-        self.scroll_offset = 1
-        self.process_exit = False
+        self.autorefresh = []
         self.height = self.term.height
-        self.width = self.term.width
-        self.set_scroll = kwargs.pop('set_scroll', True)
+        self.process_exit = False
+        self.refresh_lock = False
         self.resize_lock = False
+        self.scroll_offset = 1
+        self.width = self.term.width
+
         if not self.no_resize and RESIZE_SUPPORTED:
             self.sigwinch_orig = signal.getsignal(signal.SIGWINCH)
 
@@ -123,6 +129,7 @@ class Manager(object):
         """
         Args:
             position(int): Line number counting from the bottom of the screen
+            autorefresh(bool): Refresh this counter when other bars are drawn
             kwargs(dict): Any additional :py:term:`keyword arguments<keyword argument>`
                 are passed to :py:class:`Counter`
 
@@ -131,43 +138,130 @@ class Manager(object):
 
         Get a new progress bar instance
 
+        If ``position`` is specified, the counter's position will be pinned.
+        A :py:exc:`ValueError` will be raised if ``position`` exceeds the screen height or
+        has already been pinned by another counter.
+
+        If ``autorefresh`` is :py:data:`True`, this bar will be redrawn whenever another bar is
+        drawn assuming it had been ``min_delta`` seconds since the last update. This is usually
+        unnecessary.
+
+        .. note:: Counters are not automatically drawn when created because fields may be missing
+                  if subcounters are used. To force the counter to draw before updating,
+                  call :py:meth:`~Counter.refresh`.
+
+        """
+
+        return self._add_counter(self.counter_class, position=position, **kwargs)
+
+    def status_bar(self, *args, **kwargs):
+        """
+        Args:
+            position(int): Line number counting from the bottom of the screen
+            autorefresh(bool): Refresh this counter when other bars are drawn
+            kwargs(dict): Any additional :py:term:`keyword arguments<keyword argument>`
+                are passed to :py:class:`StatusBar`
+
+        Returns:
+            :py:class:`StatusBar`: Instance of status bar class
+
+        Get a new status bar instance
+
+        If ``position`` is specified, the counter's position can change dynamically if
+        additional counters are called without a ``position`` argument.
+
+        If ``autorefresh`` is :py:data:`True`, this bar will be redrawn whenever another bar is
+        drawn assuming it had been ``min_delta`` seconds since the last update. Generally,
+        only need when ``elapsed`` is used in :ref:`status_format <status_format>`.
+
+        """
+
+        position = kwargs.pop('position', None)
+
+        return self._add_counter(self.status_bar_class, *args, position=position, **kwargs)
+
+    def _add_counter(self, counter_class, *args, **kwargs):
+        """
+        Args:
+            counter_class(:py:class:`PrintableCounter`): Class to instantiate
+            position(int): Line number counting from the bottom of the screen
+            kwargs(dict): Any additional :py:term:`keyword arguments<keyword argument>`
+                are passed to :py:class:`Counter`
+
+        Returns:
+            :py:class:`Counter`: Instance of counter class
+
+        Get a new instance of the given class and add it to the manager
+
         If ``position`` is specified, the counter's position can change dynamically if
         additional counters are called without a ``position`` argument.
 
         """
 
+        position = kwargs.pop('position', None)
+
+        # List of counters to refresh due to new position
+        toRefresh = []
+
+        # Add default values to kwargs
         for key, val in self.defaults.items():
             if key not in kwargs:
                 kwargs[key] = val
         kwargs['manager'] = self
 
-        counter = self.counter_class(**kwargs)
-
-        if position is None:
-            toRefresh = []
-            if self.counters:
-                pos = 2
-                for cter in reversed(self.counters):
-                    if self.counters[cter] < pos:
-                        toRefresh.append(cter)
-                        cter.clear(flush=False)
-                        self.counters[cter] = pos
-                        pos += 1
-
-            self.counters[counter] = 1
-            self._set_scroll_area()
-            for cter in reversed(toRefresh):
-                cter.refresh()
-            self.stream.flush()
-
-        elif position in self.counters.values():
-            raise ValueError('Counter position %d is already occupied.' % position)
-        elif position > self.height:
-            raise ValueError('Counter position %d is greater than terminal height.' % position)
+        # Create counter
+        new = counter_class(*args, **kwargs)
+        if kwargs.pop('autorefresh', False):
+            self.autorefresh.append(new)
+
+        # Get pinned counters
+        # pylint: disable=protected-access
+        pinned = dict((pos, ctr) for ctr, pos in self.counters.items() if ctr._pinned)
+
+        # Check position
+        if position is not None:
+            if position in pinned:
+                raise ValueError('Counter position %d is already occupied.' % position)
+            if position > self.height:
+                raise ValueError('Counter position %d is greater than terminal height.' % position)
+            new._pinned = True  # pylint: disable=protected-access
+            self.counters[new] = position
+            pinned[position] = new
+            if counter_class is self.status_bar_class:
+                toRefresh.append(new)
         else:
-            self.counters[counter] = position
+            # Set for now, but will change
+            self.counters[new] = 0
+
+        # Iterate through all counters in reverse order
+        pos = 1
+        for ctr in reversed(self.counters):
+
+            if ctr in pinned.values():
+                continue
+
+            old_pos = self.counters[ctr]
+
+            while pos in pinned:
+                pos += 1
+
+            if pos != old_pos:
 
-        return counter
+                # Don't refresh new counter in case it will have subcounters
+                if ctr is not new or counter_class is self.status_bar_class:
+                    ctr.clear(flush=False)
+                    toRefresh.append(ctr)
+
+                self.counters[ctr] = pos
+
+            pos += 1
+
+        self._set_scroll_area()
+        for ctr in reversed(toRefresh):
+            ctr.refresh()
+        self.stream.flush()
+
+        return new
 
     def _resize_handler(self, *args, **kwarg):  # pylint: disable=unused-argument
         """
@@ -283,20 +377,21 @@ class Manager(object):
     def remove(self, counter):
         """
         Args:
-            counter(:py:class:`Counter`): Progress bar instance
+            counter(:py:class:`Counter`): Progress bar or status bar instance
 
-        Remove progress bar instance from manager
+        Remove bar instance from manager
 
         Does not error if instance is not managed by this manager
 
         Generally this method should not be called directly,
-        instead used :py:meth:`remove`.
+        instead used :py:meth:`Counter.close`.
         """
 
         if not counter.leave:
             try:
                 del self.counters[counter]
-            except KeyError:
+                self.autorefresh.remove(counter)
+            except (KeyError, ValueError):
                 pass
 
     def stop(self):
@@ -350,16 +445,18 @@ class Manager(object):
             if 1 in positions:
                 term.feed()
 
-    def write(self, output='', flush=True, position=0):
+    def write(self, output='', flush=True, counter=None):
         """
         Args:
-            output(str: Output string
+            output(str): Output string
             flush(bool): Flush the output stream after writing
-            position(int): Position relative to the bottom of the screen to write output
+            counter(:py:class:`Counter`): Bar being written (for position and auto-refresh)
 
         Write to stream at a given position
         """
 
+        position = self.counters[counter] if counter else 0
+
         if self.enabled:
 
             term = self.term
@@ -376,9 +473,37 @@ class Manager(object):
 
             finally:
                 # Reset position and scrolling
-                self._set_scroll_area()
-                if flush:
-                    stream.flush()
+                if not self.refresh_lock:
+                    if self.autorefresh:
+                        self._autorefresh(exclude=(counter,))
+                    self._set_scroll_area()
+                    if flush:
+                        stream.flush()
+
+    def _autorefresh(self, exclude):
+        """
+        Args:
+            exclude(list): Iterable of bars to ignore when auto-refreshing
+
+        Refresh any bars specified for auto-refresh
+        """
+
+        # Make sure this is only running once
+        try:
+            assert self.refresh_lock
+        except AssertionError:
+
+            self.refresh_lock = True
+            current_time = time.time()
+
+            for counter in self.autorefresh:
+
+                if counter in exclude or counter.min_delta > current_time - counter.last_update:
+                    continue
+
+                counter.refresh()
+
+            self.refresh_lock = False
 
 
 def get_manager(stream=None, counterclass=Counter, **kwargs):


=====================================
enlighten/_statusbar.py
=====================================
@@ -0,0 +1,222 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+**Enlighten status bar submodule**
+
+Provides StatusBar class
+"""
+
+import time
+
+from enlighten._basecounter import PrintableCounter
+from enlighten._util import format_time, Justify
+
+
+class StatusBar(PrintableCounter):
+    """
+    Args:
+        enabled(bool): Status (Default: :py:data:`True`)
+        color(str): Color as a string or RGB tuple see :ref:`Status Color <status_color>`
+        fields(dict): Additional fields used for :ref:`formating <status_format>`
+        fill(str): Fill character used in formatting and justifying text (Default: ' ')
+        justify(str):
+            One of :py:attr:`Justify.CENTER`, :py:attr:`Justify.LEFT`, :py:attr:`Justify.RIGHT`
+        leave(True): Leave status bar after closing (Default: :py:data:`True`)
+        min_delta(float): Minimum time, in seconds, between refreshes (Default: 0.1)
+        status_format(str): Status bar format, see :ref:`Format <status_format>`
+
+    Status bar class
+
+    A :py:class:`StatusBar` instance should be created with the :py:meth:`Manager.status_bar`
+    method.
+
+    .. _status_color:
+
+    **Status Color**
+
+    Color works similarly to color on :py:class:`Counter`, except it affects the entire status bar.
+    See :ref:`Series Color <series_color>` for more information.
+
+    .. _status_format:
+
+    **Format**
+
+    There are two ways to populate the status bar, direct and formatted. Direct takes
+    precedence over formatted.
+
+    .. _status_format_direct:
+
+    **Direct Status**
+
+    Direct status is used when arguments are passed to :py:meth:`Manager.status_bar` or
+    :py:meth:`StatusBar.update`. Any arguments are coerced to strings and joined with a space.
+    For example:
+
+    .. code-block:: python
+
+
+        status_bar.update('Hello', 'World!')
+        # Example output: Hello World!
+
+        status_bar.update('Hello World!')
+        # Example output: Hello World!
+
+        count = [1, 2, 3, 4]
+        status_bar.update(*count)
+         # Example output: 1 2 3 4
+
+    .. _status_format_formatted:
+
+    **Formatted Status**
+
+        Formatted status uses the format specified in the ``status_format`` parameter to populate
+        the status bar.
+
+        .. code-block:: python
+
+            'Current Stage: {stage}'
+
+            # Example output
+            'Current Stage: Testing'
+
+        Available fields:
+
+            - elapsed(:py:class:`str`) - Time elapsed since instance was created
+            - fill(:py:class:`str`) - Filled with :py:attr:`fill` until line is width of terminal.
+              May be used multiple times. Minimum width is 3.
+
+        .. note::
+
+            The status bar is only updated when :py:meth:`StatusBar.update` or
+            :py:meth:`StatusBar.refresh` is called, so fields like ``elapsed``
+            will need additional calls to appear dynamic.
+
+        User-defined fields:
+
+            Users can define fields in two ways, the ``fields`` parameter and by passing keyword
+            arguments to :py:meth:`Manager.status_bar` or :py:meth:`StatusBar.update`
+
+            The ``fields`` parameter can be used to pass a dictionary of additional
+            user-defined fields. The dictionary values can be updated after initialization to allow
+            for dynamic fields. Any fields that share names with available fields are ignored.
+
+            If fields are passed as keyword arguments to :py:meth:`Manager.status_bar` or
+            :py:meth:`StatusBar.update`, they take precedent over the ``fields`` parameter.
+
+
+    **Instance Attributes**
+
+        .. py:attribute:: elapsed
+
+            :py:class:`float` - Time since start
+
+        .. py:attribute:: enabled
+
+            :py:class:`bool` - Current status
+
+        .. py:attribute:: manager
+
+            :py:class:`Manager` - Manager Instance
+
+        .. py:attribute:: position
+
+            :py:class:`int` - Current position
+
+    """
+
+    __slots__ = ('fields', '_justify', 'status_format', '_static', '_fields')
+
+    def __init__(self, *args, **kwargs):
+        super(StatusBar, self).__init__(**kwargs)
+
+        self.fields = kwargs.pop('fields', {})
+        self._justify = None
+        self.justify = kwargs.pop('justify', Justify.LEFT)
+        self.status_format = kwargs.pop('status_format', None)
+        self._fields = kwargs
+        self._static = ' '.join(str(arg) for arg in args) if args else None
+
+    @property
+    def justify(self):
+        """
+        Maps to justify method determined by ``justify`` parameter
+        """
+        return self._justify
+
+    @justify.setter
+    def justify(self, value):
+
+        if value in (Justify.LEFT, Justify.CENTER, Justify.RIGHT):
+            self._justify = getattr(self.manager.term, value)
+
+        else:
+            raise ValueError("justify must be one of Justify.LEFT, Justify.CENTER, ",
+                             "Justify.RIGHT, not: '%r'" % value)
+
+    def format(self, width=None, elapsed=None):
+        """
+        Args:
+            width (int): Width in columns to make progress bar
+            elapsed(float): Time since started. Automatically determined if :py:data:`None`
+
+        Returns:
+            :py:class:`str`: Formatted status bar
+
+        Format status bar
+        """
+
+        width = width or self.manager.width
+        justify = self.justify
+
+        # If static message was given, just return it
+        if self._static is not None:
+            rtn = self._static
+
+        # If there is no format, return empty
+        elif self.status_format is None:
+            rtn = ''
+
+        # Generate from format
+        else:
+            fields = self.fields.copy()
+            fields.update(self._fields)
+            elapsed = elapsed if elapsed is not None else self.elapsed
+            fields['elapsed'] = format_time(elapsed)
+            fields['fill'] = u'{0}'
+
+            # Format
+            try:
+                rtn = self.status_format.format(**fields)
+            except KeyError as e:
+                raise ValueError('%r specified in format, but not provided' % e.args[0])
+
+        rtn = self._fill_text(rtn, width)
+
+        return self._colorize(justify(rtn, width=width, fillchar=self.fill))
+
+    def update(self, *objects, **fields):  # pylint: disable=arguments-differ
+        """
+        Args:
+            objects(list): Values for :ref:`Direct Status <status_format_direct>`
+            force(bool): Force refresh even if ``min_delta`` has not been reached
+            fields(dict): Fields for for :ref:`Formatted Status <status_format_formatted>`
+
+        Update status and redraw
+
+        Status bar is only redrawn if ``min_delta`` seconds past since the last update
+        """
+
+        force = fields.pop('force', False)
+
+        self._static = ' '.join(str(obj) for obj in objects) if objects else None
+        self._fields.update(fields)
+
+        if self.enabled:
+            currentTime = time.time()
+            if force or currentTime - self.last_update >= self.min_delta:
+                self.refresh(elapsed=currentTime - self.start)


=====================================
enlighten/_util.py
=====================================
@@ -0,0 +1,67 @@
+
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+**Enlighten utility submodule**
+
+Provides utility functions and objects
+"""
+
+try:
+    BASESTRING = basestring
+except NameError:
+    BASESTRING = str
+
+
+def format_time(seconds):
+    """
+    Args:
+        seconds (float): amount of time
+
+    Format time string for eta and elapsed
+    """
+
+    # Always do minutes and seconds in mm:ss format
+    minutes = seconds // 60
+    hours = minutes // 60
+    rtn = u'{0:02.0f}:{1:02.0f}'.format(minutes % 60, seconds % 60)
+
+    #  Add hours if there are any
+    if hours:
+
+        rtn = u'{0:d}h {1}'.format(int(hours % 24), rtn)
+
+        #  Add days if there are any
+        days = int(hours // 24)
+        if days:
+            rtn = u'{0:d}d {1}'.format(days, rtn)
+
+    return rtn
+
+
+class Justify(object):
+    """
+    Enumerated type for justification options
+
+    .. py:attribute:: CENTER
+
+        Justify center
+
+    .. py:attribute:: LEFT
+
+        Justify left
+
+    .. py:attribute:: RIGHT
+
+        Justify right
+
+    """
+
+    CENTER = 'center'
+    LEFT = 'ljust'
+    RIGHT = 'rjust'


=====================================
enlighten/counter.py
=====================================
@@ -15,6 +15,7 @@ import sys
 
 from enlighten._counter import Counter as _Counter
 from enlighten._counter import SubCounter  # pylint: disable=unused-import # noqa: F401
+from enlighten._statusbar import StatusBar  # pylint: disable=unused-import # noqa: F401
 from enlighten._manager import get_manager
 
 


=====================================
examples/demo.py
=====================================
@@ -26,7 +26,7 @@ def initialize(manager, initials=15):
     # Simulated preparation
     pbar = manager.counter(total=initials, desc='Initializing:', unit='initials')
     for num in range(initials):  # pylint: disable=unused-variable
-        time.sleep(random.uniform(0.1, 0.5))  # Random processing time
+        time.sleep(random.uniform(0.05, 0.25))  # Random processing time
         pbar.update()
     pbar.close()
 
@@ -37,9 +37,21 @@ def main():
     """
 
     with enlighten.get_manager() as manager:
+        status = manager.status_bar(status_format=u'Enlighten{fill}Stage: {demo}{fill}{elapsed}',
+                                    color='bold_underline_bright_white_on_lightslategray',
+                                    justify=enlighten.Justify.CENTER, demo='Initializing',
+                                    autorefresh=True, min_delta=0.5)
+        docs = manager.term.link('https://python-enlighten.readthedocs.io/en/stable/examples.html',
+                                 'Read the Docs')
+        manager.status_bar(' More examples on %s! ' % docs, position=1, fill='-',
+                           justify=enlighten.Justify.CENTER)
+
         initialize(manager, 15)
-        load(manager, 80)
-        run_tests(manager, 40)
+        status.update(demo='Loading')
+        load(manager, 40)
+        status.update(demo='Testing')
+        run_tests(manager, 20)
+        status.update(demo='File Processing')
         process_files(manager)
 
 


=====================================
examples/multicolored.py
=====================================
@@ -89,15 +89,15 @@ def run_tests(manager, tests=100):
 
     terminal = manager.term
     bar_format = u'{desc}{desc_pad}{percentage:3.0f}%|{bar}| ' + \
-                 u'S:' + terminal.green(u'{count_0:{len_total}d}') + u' ' + \
-                 u'F:' + terminal.red(u'{count_2:{len_total}d}') + u' ' + \
-                 u'E:' + terminal.yellow(u'{count_1:{len_total}d}') + u' ' + \
+                 u'S:' + terminal.green3(u'{count_0:{len_total}d}') + u' ' + \
+                 u'F:' + terminal.red2(u'{count_2:{len_total}d}') + u' ' + \
+                 u'E:' + terminal.yellow2(u'{count_1:{len_total}d}') + u' ' + \
                  u'[{elapsed}<{eta}, {rate:.2f}{unit_pad}{unit}/s]'
 
-    with manager.counter(total=tests, desc='Testing', unit='tests', color='green',
+    with manager.counter(total=tests, desc='Testing', unit='tests', color='green3',
                          bar_format=bar_format) as success:
-        errors = success.add_subcounter('yellow')
-        failures = success.add_subcounter('red')
+        errors = success.add_subcounter('yellow2')
+        failures = success.add_subcounter('red2')
 
         for num in range(tests):
             time.sleep(random.uniform(0.1, 0.3))  # Random processing time
@@ -120,9 +120,9 @@ def load(manager, units=80):
     """
 
     pb_connecting = manager.counter(total=units, desc='Loading', unit='services',
-                                    color='red', bar_format=BAR_FMT)
-    pb_loading = pb_connecting.add_subcounter('yellow')
-    pb_loaded = pb_connecting.add_subcounter('green', all_fields=True)
+                                    color='red2', bar_format=BAR_FMT)
+    pb_loading = pb_connecting.add_subcounter('yellow2')
+    pb_loaded = pb_connecting.add_subcounter('green3', all_fields=True)
 
     connecting = []
     loading = []


=====================================
examples/multiple_logging.py
=====================================
@@ -21,7 +21,7 @@ logging.basicConfig(level=logging.INFO)
 LOGGER = logging.getLogger('enlighten')
 
 DATACENTERS = 5
-SYSTEMS = (10, 20)  # Range
+SYSTEMS = (5, 10)  # Range
 FILES = (10, 100)  # Range
 
 


=====================================
pylintrc
=====================================
@@ -6,6 +6,10 @@ output-format=colorized
 # Use max line length of 100
 max-line-length=100
 
+[DESIGN]
+# Maximum number of branch for function / method body.
+max-branches=15
+
 [BASIC]
 # As far as I can tell, PEP-8 (Nov 1, 2013) does not specify
 # a specific naming convention for variables and arguments
@@ -32,7 +36,7 @@ spelling-dict=en_US
 # List of comma separated words that should not be checked.
 spelling-ignore-words=
     Avram, ansicon, Args, assertRaisesRegexp, assertRegexpMatches, assertNotRegexpMatches, attr,
-    AttributeError,
+    AttributeError, autorefresh,
     BaseManager, bool,
     desc, downconverted, downloader,
     Enlighten's, exc,
@@ -42,9 +46,11 @@ spelling-ignore-words=
     len, Lubkin,
     meth, Mozilla, MPL,
     noqa,
-    pragma, py,
+    peru, pragma, py,
     redirector, resize, resizing, RGB,
-    setscroll, sphinxcontrib, ss, stdout, stderr, str, subcounter, subcounters, submodule,
+    seagreen, setscroll, sphinxcontrib, ss, StatusBar, stdout,
+    stderr, str, subcounter, subcounters, submodule,
     subprocesses, sys,
     TestCase, tty, TTY, tuple,
-    unicode, unittest
+    unicode, unittest,
+    ValueError


=====================================
setup.py
=====================================
@@ -17,7 +17,7 @@ from setuptools import setup, find_packages
 
 from setup_helpers import get_version, readme
 
-INSTALL_REQUIRES = ['blessed>=1.17.2']
+INSTALL_REQUIRES = ['blessed>=1.17.7']
 TESTS_REQUIRE = ['mock; python_version < "3.3"',
                  'unittest2; python_version < "2.7"']
 


=====================================
tests/__init__.py
=====================================
@@ -19,7 +19,9 @@ import sys
 import termios
 
 from enlighten import Manager
-from enlighten._counter import Counter, BaseCounter
+from enlighten._basecounter import BaseCounter
+from enlighten._counter import Counter
+from enlighten._statusbar import StatusBar
 
 # pylint: disable=import-error
 
@@ -140,6 +142,9 @@ class MockTTY(object):
         self.stdout.close()
         self.stdread.close()
 
+    def clear(self):
+        termios.tcflush(self.stdread, termios.TCIFLUSH)
+
     def resize(self, height, width):
         fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack('hhhh', height, width, 0, 0))
 
@@ -149,7 +154,7 @@ class MockBaseCounter(BaseCounter):
     Mock version of base counter for testing
     """
 
-    def update(self, incr=1, force=False):
+    def update(self, *args, **kwargs):
         """
         Simple update that updates the count. We know it's called based on the count.
         """
@@ -174,12 +179,32 @@ class MockCounter(Counter):
         self.calls.append('clear(flush=%s)' % flush)
 
 
+class MockStatusBar(StatusBar):
+
+    __slots__ = ('called', 'calls')
+
+    def __init__(self, *args, **kwargs):
+        super(MockStatusBar, self).__init__(*args, **kwargs)
+        self.called = 0
+        self.calls = []
+
+    def refresh(self, flush=True, elapsed=None):
+        self.called += 1
+        self.calls.append('refresh(flush=%s, elapsed=%s)' % (flush, elapsed))
+
+
 class MockManager(Manager):
     # pylint: disable=super-init-not-called
     def __init__(self, counter_class=Counter, **kwargs):
         super(MockManager, self).__init__(counter_class=counter_class, **kwargs)
         self.width = 80
         self.output = []
+        self.remove_calls = 0
+
+    def write(self, output='', flush=True, counter=None):
+        self.output.append('write(output=%s, flush=%s, position=%s)' %
+                           (output, flush, counter.position))
 
-    def write(self, output='', flush=True, position=0):
-        self.output.append('write(output=%s, flush=%s, position=%s)' % (output, flush, position))
+    def remove(self, counter):
+        self.remove_calls += 1
+        super(MockManager, self).remove(counter)


=====================================
tests/test_basecounter.py
=====================================
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+Test module for enlighten._counter and enlighten.counter
+"""
+
+from types import GeneratorType
+
+from enlighten._basecounter import BaseCounter
+
+from tests import TestCase, MockManager, MockTTY, MockBaseCounter
+
+
+# pylint: disable=protected-access
+class TestBaseCounter(TestCase):
+    """
+    Test the BaseCounter class
+    """
+
+    def setUp(self):
+        self.tty = MockTTY()
+        self.manager = MockManager(stream=self.tty.stdout)
+
+    def tearDown(self):
+        self.tty.close()
+
+    def test_init_default(self):
+        """Ensure default values are set"""
+        counter = BaseCounter(manager=self.manager)
+        self.assertIsNone(counter.color)
+        self.assertIsNone(counter.color)
+        self.assertIs(counter.manager, self.manager)
+        self.assertEqual(counter.count, 0)
+        self.assertEqual(counter.start_count, 0)
+
+    def test_no_manager(self):
+        """Raise an error if there is no manager specified"""
+        with self.assertRaisesRegex(TypeError, 'manager must be specified'):
+            BaseCounter()
+
+    def test_color_invalid(self):
+        """Color must be a valid string, RGB, or int 0 - 255"""
+        # Unsupported type
+        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 1.0'):
+            BaseCounter(manager=self.manager, color=1.0)
+
+        # Invalid String
+        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: buggersnot'):
+            BaseCounter(manager=self.manager, color='buggersnot')
+
+        # Invalid integer
+        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: -1'):
+            BaseCounter(manager=self.manager, color=-1)
+        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 256'):
+            BaseCounter(manager=self.manager, color=256)
+
+        # Invalid iterable
+        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \[\]'):
+            BaseCounter(manager=self.manager, color=[])
+        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \[1\]'):
+            BaseCounter(manager=self.manager, color=[1])
+        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2\)'):
+            BaseCounter(manager=self.manager, color=(1, 2))
+        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2, 3, 4\)'):
+            BaseCounter(manager=self.manager, color=(1, 2, 3, 4))
+
+    def test_colorize_none(self):
+        """If color is None, return content unchanged"""
+        counter = BaseCounter(manager=self.manager)
+        self.assertEqual(counter._colorize('test'), 'test')
+
+    def test_colorize_string(self):
+        """Return string formatted with color (string)"""
+        counter = BaseCounter(manager=self.manager, color='red')
+        self.assertEqual(counter.color, 'red')
+        self.assertEqual(counter._color, ('red', self.manager.term.red))
+        self.assertNotEqual(counter._colorize('test'), 'test')
+        self.assertEqual(counter._colorize('test'), self.manager.term.red('test'))
+
+    def test_colorize_string_compound(self):
+        """Return string formatted with compound color (string)"""
+        counter = BaseCounter(manager=self.manager, color='bold_red_on_blue')
+        self.assertEqual(counter.color, 'bold_red_on_blue')
+        self.assertEqual(counter._color, ('bold_red_on_blue', self.manager.term.bold_red_on_blue))
+        self.assertNotEqual(counter._colorize('test'), 'test')
+        self.assertEqual(counter._colorize('test'), self.manager.term.bold_red_on_blue('test'))
+
+    def test_colorize_int(self):
+        """Return string formatted with color (int)"""
+        counter = BaseCounter(manager=self.manager, color=40)
+        self.assertEqual(counter.color, 40)
+        self.assertEqual(counter._color, (40, self.manager.term.color(40)))
+        self.assertNotEqual(counter._colorize('test'), 'test')
+        self.assertEqual(counter._colorize('test'), self.manager.term.color(40)('test'))
+
+    def test_colorize_rgb(self):
+        """Return string formatted with color (RGB)"""
+        counter = BaseCounter(manager=self.manager, color=(50, 40, 60))
+        self.assertEqual(counter.color, (50, 40, 60))
+        self.assertEqual(counter._color, ((50, 40, 60), self.manager.term.color_rgb(50, 40, 60)))
+        self.assertNotEqual(counter._colorize('test'), 'test')
+        self.assertEqual(counter._colorize('test'), self.manager.term.color_rgb(50, 40, 60)('test'))
+
+    def test_call(self):
+        """Returns generator when used as a function"""
+
+        # Bad arguments
+        counter = MockBaseCounter(manager=self.manager)
+        with self.assertRaisesRegex(TypeError, 'Argument type int is not iterable'):
+            list(counter(1))
+        with self.assertRaisesRegex(TypeError, 'Argument type bool is not iterable'):
+            list(counter([1, 2, 3], True))
+
+        # Expected
+        counter = MockBaseCounter(manager=self.manager)
+        rtn = counter([1, 2, 3])
+        self.assertIsInstance(rtn, GeneratorType)
+        self.assertEqual(list(rtn), [1, 2, 3])
+        self.assertEqual(counter.count, 3)
+
+        # Multiple arguments
+        counter = MockBaseCounter(manager=self.manager)
+        rtn = counter([1, 2, 3], (3, 2, 1))
+        self.assertIsInstance(rtn, GeneratorType)
+        self.assertEqual(tuple(rtn), (1, 2, 3, 3, 2, 1))
+        self.assertEqual(counter.count, 6)


=====================================
tests/test_counter.py
=====================================
@@ -10,13 +10,12 @@ Test module for enlighten._counter and enlighten.counter
 """
 
 import time
-from types import GeneratorType
 
 from enlighten import Counter, Manager
 import enlighten._counter
 from enlighten._manager import NEEDS_UNICODE_HELP
 
-from tests import TestCase, mock, MockManager, MockTTY, MockCounter, MockBaseCounter
+from tests import TestCase, mock, MockManager, MockTTY, MockCounter
 
 
 # pylint: disable=missing-docstring, protected-access, too-many-public-methods
@@ -24,141 +23,6 @@ SERIES_STD = u' ▏▎▍▌▋▊▉█'
 BLOCK = enlighten._counter.SERIES_STD[-1]
 
 
-class TestFormatTime(TestCase):
-    """
-    Test cases for :py:func:`_format_time`
-    """
-
-    def test_seconds(self):
-
-        self.assertEqual(enlighten._counter._format_time(0), '00:00')
-        self.assertEqual(enlighten._counter._format_time(6), '00:06')
-        self.assertEqual(enlighten._counter._format_time(42), '00:42')
-
-    def test_minutes(self):
-
-        self.assertEqual(enlighten._counter._format_time(60), '01:00')
-        self.assertEqual(enlighten._counter._format_time(128), '02:08')
-        self.assertEqual(enlighten._counter._format_time(1684), '28:04')
-
-    def test_hours(self):
-
-        self.assertEqual(enlighten._counter._format_time(3600), '1h 00:00')
-        self.assertEqual(enlighten._counter._format_time(43980), '12h 13:00')
-        self.assertEqual(enlighten._counter._format_time(43998), '12h 13:18')
-
-    def test_days(self):
-
-        self.assertEqual(enlighten._counter._format_time(86400), '1d 0h 00:00')
-        self.assertEqual(enlighten._counter._format_time(1447597), '16d 18h 06:37')
-
-
-class TestBaseCounter(TestCase):
-    """
-    Test the BaseCounter class
-    """
-
-    def setUp(self):
-        self.tty = MockTTY()
-        self.manager = MockManager(stream=self.tty.stdout)
-
-    def tearDown(self):
-        self.tty.close()
-
-    def test_init_default(self):
-        """Ensure default values are set"""
-        counter = enlighten._counter.BaseCounter(manager=self.manager)
-        self.assertIsNone(counter.color)
-        self.assertIsNone(counter.color)
-        self.assertIs(counter.manager, self.manager)
-        self.assertEqual(counter.count, 0)
-        self.assertEqual(counter.start_count, 0)
-
-    def test_no_manager(self):
-        """Raise an error if there is no manager specified"""
-        with self.assertRaisesRegex(TypeError, 'manager must be specified'):
-            enlighten._counter.BaseCounter()
-
-    def test_color_invalid(self):
-        """Color must be a valid string, RGB, or int 0 - 255"""
-        # Unsupported type
-        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 1.0'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=1.0)
-
-        # Invalid String
-        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: buggersnot'):
-            enlighten._counter.BaseCounter(manager=self.manager, color='buggersnot')
-
-        # Invalid integer
-        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: -1'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=-1)
-        with self.assertRaisesRegex(AttributeError, 'Invalid color specified: 256'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=256)
-
-        # Invalid iterable
-        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \[\]'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=[])
-        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \[1\]'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=[1])
-        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2\)'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=(1, 2))
-        with self.assertRaisesRegex(AttributeError, r'Invalid color specified: \(1, 2, 3, 4\)'):
-            enlighten._counter.BaseCounter(manager=self.manager, color=(1, 2, 3, 4))
-
-    def test_colorize_none(self):
-        """If color is None, return content unchanged"""
-        counter = enlighten._counter.BaseCounter(manager=self.manager)
-        self.assertEqual(counter._colorize('test'), 'test')
-
-    def test_colorize_string(self):
-        """Return string formatted with color (string)"""
-        counter = enlighten._counter.BaseCounter(manager=self.manager, color='red')
-        self.assertEqual(counter.color, 'red')
-        self.assertEqual(counter._color, ('red', self.manager.term.red))
-        self.assertNotEqual(counter._colorize('test'), 'test')
-        self.assertEqual(counter._colorize('test'), self.manager.term.red('test'))
-
-    def test_colorize_int(self):
-        """Return string formatted with color (int)"""
-        counter = enlighten._counter.BaseCounter(manager=self.manager, color=40)
-        self.assertEqual(counter.color, 40)
-        self.assertEqual(counter._color, (40, self.manager.term.color(40)))
-        self.assertNotEqual(counter._colorize('test'), 'test')
-        self.assertEqual(counter._colorize('test'), self.manager.term.color(40)('test'))
-
-    def test_colorize_rgb(self):
-        """Return string formatted with color (RGB)"""
-        counter = enlighten._counter.BaseCounter(manager=self.manager, color=(50, 40, 60))
-        self.assertEqual(counter.color, (50, 40, 60))
-        self.assertEqual(counter._color, ((50, 40, 60), self.manager.term.color_rgb(50, 40, 60)))
-        self.assertNotEqual(counter._colorize('test'), 'test')
-        self.assertEqual(counter._colorize('test'), self.manager.term.color_rgb(50, 40, 60)('test'))
-
-    def test_call(self):
-        """Returns generator when used as a function"""
-
-        # Bad arguments
-        counter = MockBaseCounter(manager=self.manager)
-        with self.assertRaisesRegex(TypeError, 'Argument type int is not iterable'):
-            list(counter(1))
-        with self.assertRaisesRegex(TypeError, 'Argument type bool is not iterable'):
-            list(counter([1, 2, 3], True))
-
-        # Expected
-        counter = MockBaseCounter(manager=self.manager)
-        rtn = counter([1, 2, 3])
-        self.assertIsInstance(rtn, GeneratorType)
-        self.assertEqual(list(rtn), [1, 2, 3])
-        self.assertEqual(counter.count, 3)
-
-        # Multiple arguments
-        counter = MockBaseCounter(manager=self.manager)
-        rtn = counter([1, 2, 3], (3, 2, 1))
-        self.assertIsInstance(rtn, GeneratorType)
-        self.assertEqual(tuple(rtn), (1, 2, 3, 3, 2, 1))
-        self.assertEqual(counter.count, 6)
-
-
 class TestSubCounter(TestCase):
     """
     Test the BaseCounter class
@@ -370,9 +234,11 @@ class TestCounter(TestCase):
         self.assertEqual(int(ctr.elapsed), 3)
 
     def test_refresh(self):
+        self.ctr.last_update = 0
         self.ctr.refresh()
         self.assertRegex(self.manager.output[0],
                          r'write\(output=%s, flush=True, position=3\)' % self.output)
+        self.assertAlmostEqual(self.ctr.last_update, time.time(), delta=0.3)
 
         self.manager.output = []
         self.ctr.refresh(flush=False)
@@ -385,8 +251,10 @@ class TestCounter(TestCase):
         self.assertEqual(len(self.manager.output), 0)
 
     def test_clear(self):
+        self.ctr.last_update = 100
         self.ctr.clear()
         self.assertRegex(self.manager.output[0], r'write\(output=, flush=True, position=3\)')
+        self.assertEqual(self.ctr.last_update, 0)
 
         self.manager.output = []
         self.ctr.clear(flush=False)
@@ -719,25 +587,25 @@ class TestCounter(TestCase):
         self.assertEqual(formatted, u'350 | 50 1.0 | 100')
 
     def test_close(self):
-        manager = mock.Mock()
+        manager = MockManager()
 
         # Clear is False
         ctr = MockCounter(manager=manager)
         ctr.close()
         self.assertEqual(ctr.calls, ['refresh(flush=True, elapsed=None)'])
-        self.assertEqual(manager.remove.call_count, 1)
+        self.assertEqual(manager.remove_calls, 1)
 
         # Clear is True, leave is True
         ctr = MockCounter(manager=manager, leave=True)
         ctr.close(clear=True)
         self.assertEqual(ctr.calls, ['refresh(flush=True, elapsed=None)'])
-        self.assertEqual(manager.remove.call_count, 2)
+        self.assertEqual(manager.remove_calls, 2)
 
         # Clear is True, leave is False
         ctr = MockCounter(manager=manager, leave=False)
         ctr.close(clear=True)
         self.assertEqual(ctr.calls, ['clear(flush=True)'])
-        self.assertEqual(manager.remove.call_count, 3)
+        self.assertEqual(manager.remove_calls, 3)
 
     def test_context_manager(self):
         mgr = Manager(stream=self.tty.stdout, enabled=False)
@@ -776,11 +644,11 @@ class TestCounter(TestCase):
         bar_format = ctr_format = u'{arg1:s} {count:d}'
 
         ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format,
-                      additional_fields={'arg1': 'hello'})
+                      fields={'arg1': 'hello'})
         self.assertEqual(ctr.format(), 'hello 1')
 
         ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format,
-                      additional_fields={'arg1': 'hello'})
+                      fields={'arg1': 'hello'})
         self.assertEqual(ctr.format(), 'hello 1')
 
     def test_additional_fields_missing(self):
@@ -791,11 +659,11 @@ class TestCounter(TestCase):
         bar_format = ctr_format = u'{arg1:s} {count:d}'
 
         ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format)
-        with self.assertRaisesRegex(ValueError, "'arg1' specified in format, but not present"):
+        with self.assertRaisesRegex(ValueError, "'arg1' specified in format, but not provided"):
             ctr.format()
 
         ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format)
-        with self.assertRaisesRegex(ValueError, "'arg1' specified in format, but not present"):
+        with self.assertRaisesRegex(ValueError, "'arg1' specified in format, but not provided"):
             ctr.format()
 
     def test_additional_fields_changed(self):
@@ -807,13 +675,13 @@ class TestCounter(TestCase):
         additional_fields = {'arg1': 'hello'}
 
         ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format,
-                      additional_fields=additional_fields)
+                      fields=additional_fields)
         self.assertEqual(ctr.format(), 'hello 1')
         additional_fields['arg1'] = 'goodbye'
         self.assertEqual(ctr.format(), 'goodbye 1')
 
         ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format,
-                      additional_fields=additional_fields)
+                      fields=additional_fields)
         self.assertEqual(ctr.format(), 'goodbye 1')
         additional_fields['arg1'] = 'hello'
         self.assertEqual(ctr.format(), 'hello 1')
@@ -827,9 +695,70 @@ class TestCounter(TestCase):
         additional_fields = {'arg1': 'hello', 'count': 100000}
 
         ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format,
-                      additional_fields=additional_fields)
+                      fields=additional_fields)
         self.assertEqual(ctr.format(), 'hello 1')
 
         ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format,
-                      additional_fields=additional_fields)
+                      fields=additional_fields)
+        self.assertEqual(ctr.format(), 'hello 1')
+
+    def test_kwarg_fields(self):
+        """
+        Additional fields to format via keyword arguments
+        """
+
+        bar_format = ctr_format = u'{arg1:s} {count:d}'
+
+        ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format,
+                      arg1='hello')
+        self.assertEqual(ctr.format(), 'hello 1')
+
+        ctr.update(arg1='goodbye')
+        self.assertEqual(ctr.format(), 'goodbye 2')
+
+        ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format,
+                      arg1='hello')
+        self.assertEqual(ctr.format(), 'hello 1')
+
+        ctr.update(arg1='goodbye')
+        self.assertEqual(ctr.format(), 'goodbye 2')
+
+    def test_kwarg_fields_precedence(self):
+        """
+        Keyword arguments take precedence over fields
+        """
+
+        bar_format = u'{arg1:s} {count:d}'
+        additional_fields = {'arg1': 'hello'}
+
+        ctr = Counter(stream=self.tty.stdout, total=10, count=1, bar_format=bar_format,
+                      fields=additional_fields)
+
         self.assertEqual(ctr.format(), 'hello 1')
+
+        ctr.update(arg1='goodbye')
+        self.assertEqual(ctr.format(), 'goodbye 2')
+
+    def test_fill_setter(self):
+        """Fill must be one printable character"""
+
+        ctr = Counter(stream=self.tty.stdout, fill='a')
+
+        with self.assertRaisesRegex(ValueError, 'fill character must be a length of 1'):
+            ctr.fill = 'hello'
+
+        with self.assertRaisesRegex(ValueError, 'fill character must be a length of 1'):
+            ctr.fill = ''
+
+    def test_fill(self):
+        """
+        Fill uses remaining space
+        """
+
+        ctr_format = u'{fill}HI'
+        ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format, fill=u'-')
+        self.assertEqual(ctr.format(), u'-' * 78 + 'HI')
+
+        ctr_format = u'{fill}HI{fill}'
+        ctr = Counter(stream=self.tty.stdout, count=1, counter_format=ctr_format, fill=u'-')
+        self.assertEqual(ctr.format(), u'-' * 39 + 'HI' + u'-' * 39)


=====================================
tests/test_manager.py
=====================================
@@ -11,6 +11,7 @@ Test module for enlighten._manager
 
 import signal
 import sys
+import time
 
 from enlighten import _manager
 
@@ -192,6 +193,35 @@ class TestManager(TestCase):
                                     'Counter position 200 is greater than terminal height'):
             manager.counter(position=200)
 
+    def test_counter_position_pinned(self):
+        """If a position is taken, use next available"""
+
+        manager = _manager.Manager(stream=self.tty.stdout, set_scroll=False)
+        counter1 = manager.counter(position=2)
+        self.assertEqual(manager.counters[counter1], 2)
+
+        counter2 = manager.counter()
+        self.assertEqual(manager.counters[counter1], 2)
+        self.assertEqual(manager.counters[counter2], 1)
+
+        counter3 = manager.counter()
+        self.assertEqual(manager.counters[counter1], 2)
+        self.assertEqual(manager.counters[counter2], 3)
+        self.assertEqual(manager.counters[counter3], 1)
+
+        status1 = manager.status_bar(position=3)
+        self.assertEqual(manager.counters[counter1], 2)
+        self.assertEqual(manager.counters[counter2], 4)
+        self.assertEqual(manager.counters[counter3], 1)
+        self.assertEqual(manager.counters[status1], 3)
+
+        status2 = manager.status_bar()
+        self.assertEqual(manager.counters[counter1], 2)
+        self.assertEqual(manager.counters[counter2], 5)
+        self.assertEqual(manager.counters[counter3], 4)
+        self.assertEqual(manager.counters[status1], 3)
+        self.assertEqual(manager.counters[status2], 1)
+
     def test_inherit_kwargs(self):
         manager = _manager.Manager(stream=self.tty.stdout, counter_class=MockCounter,
                                    unit='knights', not_real=True, desc='Default')
@@ -212,14 +242,15 @@ class TestManager(TestCase):
 
         with mock.patch('enlighten._manager.Manager._set_scroll_area') as ssa:
             manager = _manager.Manager(stream=self.tty.stdout)
+            counter = manager.counter(position=3)
             term = manager.term
-            manager.write(msg, position=3)
+            manager.write(msg, counter=counter)
 
         self.tty.stdout.write(u'X\n')
         # Carriage return is getting converted to newline
         self.assertEqual(self.tty.stdread.readline(),
                          term.move(22, 0) + '\r' + term.clear_eol + msg + 'X\n')
-        self.assertEqual(ssa.call_count, 1)
+        self.assertEqual(ssa.call_count, 2)
 
     def test_write_no_flush(self):
         """
@@ -233,14 +264,43 @@ class TestManager(TestCase):
 
         with mock.patch('enlighten._manager.Manager._set_scroll_area') as ssa:
             manager = _manager.Manager(stream=self.tty.stdout)
+            counter = manager.counter(position=3)
             term = manager.term
-            manager.write(msg, position=3, flush=False)
+            manager.write(msg, counter=counter, flush=False)
 
         self.tty.stdout.write(u'X\n')
         # Carriage return is getting converted to newline
         self.assertEqual(self.tty.stdread.readline(),
                          term.move(22, 0) + '\r' + term.clear_eol + msg + 'X\n')
-        self.assertEqual(ssa.call_count, 1)
+        self.assertEqual(ssa.call_count, 2)
+
+    def test_autorefresh(self):
+        """
+        Ensure auto-refreshed counters are updated when others are
+        """
+
+        manager = _manager.Manager(stream=self.tty.stdout)
+        counter1 = manager.counter(count=1, total=0, counter_format=u'counter1', autorefresh=True)
+        counter2 = manager.counter(count=1, total=0, counter_format=u'counter2')
+        self.tty.clear()
+
+        # Counter 1 in auto-refresh list
+        self.assertIn(counter1, manager.autorefresh)
+
+        # If auto-refreshed counter hasn't been refreshed recently refresh
+        counter1.last_update = 0
+        counter2.refresh()
+        self.tty.stdout.write(u'X\n')
+        output = self.tty.stdread.readline()
+        self.assertRegex(output, 'counter2.+counter1')
+
+        # If auto-refreshed counter has been refreshed recently, skip
+        counter1.last_update = time.time() + 5
+        counter2.refresh()
+        self.tty.stdout.write(u'X\n')
+        output = self.tty.stdread.readline()
+        self.assertRegex(output, 'counter2')
+        self.assertNotRegex(output, 'counter1')
 
     def test_set_scroll_area_disabled(self):
         manager = _manager.Manager(stream=self.tty.stdout,


=====================================
tests/test_statusbar.py
=====================================
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+Test module for enlighten._statusbar
+"""
+
+from enlighten import Justify
+
+from tests import TestCase, MockManager, MockTTY, MockStatusBar
+
+
+class TestStatusBar(TestCase):
+    """
+    Test the StatusBar class
+    """
+
+    def setUp(self):
+        self.tty = MockTTY()
+        self.manager = MockManager(stream=self.tty.stdout)
+
+    def tearDown(self):
+        self.tty.close()
+
+    def test_static(self):
+        """
+        Basic static status bar
+        """
+
+        sbar = self.manager.status_bar('Hello', 'World!')
+        self.assertEqual(sbar.format(), 'Hello World!' + ' ' * 68)
+
+        sbar.update('Goodbye, World!')
+        self.assertEqual(sbar.format(), 'Goodbye, World!' + ' ' * 65)
+
+    def test_static_justify(self):
+        """
+        Justified static status bar
+        """
+
+        sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.LEFT)
+        self.assertEqual(sbar.format(), 'Hello World!' + ' ' * 68)
+
+        sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.RIGHT)
+        self.assertEqual(sbar.format(), ' ' * 68 + 'Hello World!')
+
+        sbar = self.manager.status_bar('Hello', 'World!', justify=Justify.CENTER)
+        self.assertEqual(sbar.format(), ' ' * 34 + 'Hello World!' + ' ' * 34)
+
+    def test_formatted(self):
+        """
+        Basic formatted status bar
+        """
+
+        sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1,
+                                       fields={'status': 'All good!'})
+        self.assertEqual(sbar.format(), 'Stage: 1, Status: All good!' + ' ' * 53)
+        sbar.update(stage=2)
+        self.assertEqual(sbar.format(), 'Stage: 2, Status: All good!' + ' ' * 53)
+        sbar.update(stage=3, status='Meh')
+        self.assertEqual(sbar.format(), 'Stage: 3, Status: Meh' + ' ' * 59)
+
+    def test_formatted_justify(self):
+        """
+        Justified formatted status bar
+        """
+
+        sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1,
+                                       fields={'status': 'All good!'}, justify=Justify.LEFT)
+        self.assertEqual(sbar.format(), 'Stage: 1, Status: All good!' + ' ' * 53)
+
+        sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1,
+                                       fields={'status': 'All good!'}, justify=Justify.RIGHT)
+        self.assertEqual(sbar.format(), ' ' * 53 + 'Stage: 1, Status: All good!')
+
+        sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1,
+                                       fields={'status': 'All good'}, justify=Justify.CENTER)
+        self.assertEqual(sbar.format(), ' ' * 27 + 'Stage: 1, Status: All good' + ' ' * 27)
+
+    def test_formatted_missing_field(self):
+        """
+        ValueError raised when a field is missing when updating status bar
+        """
+
+        fields = {'status': 'All good!'}
+        sbar = self.manager.status_bar(status_format=u'Stage: {stage}, Status: {status}', stage=1,
+                                       fields=fields)
+        del fields['status']
+
+        sbar.last_update = sbar.start - 5.0
+        with self.assertRaisesRegex(ValueError, "'status' specified in format, but not provided"):
+            sbar.update()
+
+    def test_bad_justify(self):
+        """
+        ValueError raised when justify is given an invalid value
+        """
+
+        with self.assertRaisesRegex(ValueError, 'justify must be one of Justify.LEFT, '):
+            self.manager.status_bar('Hello', 'World!', justify='justice')
+
+    def test_update(self):
+        """
+        update() does not refresh is bar is disabled or min_delta hasn't passed
+        """
+
+        self.manager.status_bar_class = MockStatusBar
+        sbar = self.manager.status_bar('Hello', 'World!')
+
+        self.assertEqual(sbar.called, 1)
+        sbar.last_update = sbar.start - 1.0
+        sbar.update()
+        self.assertEqual(sbar.called, 2)
+
+        sbar.last_update = sbar.start + 5.0
+        sbar.update()
+        self.assertEqual(sbar.called, 2)
+
+        sbar.last_update = sbar.last_update - 10.0
+        sbar.enabled = False
+        sbar.update()
+        self.assertEqual(sbar.called, 2)
+
+        sbar.enabled = True
+        sbar.update()
+        self.assertEqual(sbar.called, 3)
+
+    def test_fill(self):
+        """
+        Fill uses remaining space
+        """
+
+        sbar = self.manager.status_bar(status_format=u'{fill}HI', fill='-')
+        self.assertEqual(sbar.format(), u'-' * 78 + 'HI')
+
+        sbar = self.manager.status_bar(status_format=u'{fill}HI{fill}', fill='-')
+        self.assertEqual(sbar.format(), u'-' * 39 + 'HI' + u'-' * 39)
+
+    def test_fill_uneven(self):
+        """
+        Extra fill should be equal
+        """
+
+        print(self.manager.term.width)
+        sbar = self.manager.status_bar(
+            status_format=u'{fill}Helloooo!{fill}Woooorld!{fill}', fill='-'
+        )
+        self.assertEqual(sbar.format(),
+                         u'-' * 20 + 'Helloooo!' + u'-' * 21 + 'Woooorld!' + u'-' * 21)


=====================================
tests/test_util.py
=====================================
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""
+Test module for enlighten._util
+"""
+
+from enlighten._util import format_time
+
+from tests import TestCase
+
+
+class TestFormatTime(TestCase):
+    """
+    Test cases for :py:func:`_format_time`
+    """
+
+    def test_seconds(self):
+        """Verify seconds formatting"""
+
+        self.assertEqual(format_time(0), '00:00')
+        self.assertEqual(format_time(6), '00:06')
+        self.assertEqual(format_time(42), '00:42')
+
+    def test_minutes(self):
+        """Verify minutes formatting"""
+
+        self.assertEqual(format_time(60), '01:00')
+        self.assertEqual(format_time(128), '02:08')
+        self.assertEqual(format_time(1684), '28:04')
+
+    def test_hours(self):
+        """Verify hours formatting"""
+
+        self.assertEqual(format_time(3600), '1h 00:00')
+        self.assertEqual(format_time(43980), '12h 13:00')
+        self.assertEqual(format_time(43998), '12h 13:18')
+
+    def test_days(self):
+        """Verify days formatting"""
+
+        self.assertEqual(format_time(86400), '1d 0h 00:00')
+        self.assertEqual(format_time(1447597), '16d 18h 06:37')


=====================================
tox.ini
=====================================
@@ -24,7 +24,7 @@ commands =
 [testenv:el7]
 basepython = python2.7
 deps =
-    blessed == 1.17.2
+    blessed == 1.17.8
     mock == 1.0.1
     # setuptools == 0.9.8 (Doesn't support PEP 508)
     setuptools == 20.2.2



View it on GitLab: https://salsa.debian.org/med-team/enlighten/-/commit/d1545d97e8764050a4c440b8ce8a007e0ff3b461

-- 
View it on GitLab: https://salsa.debian.org/med-team/enlighten/-/commit/d1545d97e8764050a4c440b8ce8a007e0ff3b461
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20200713/d3e62a82/attachment-0001.html>


More information about the debian-med-commit mailing list