diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index e5ec1f57..00000000 --- a/.coveragerc +++ /dev/null @@ -1,25 +0,0 @@ -[run] -branch = True -source = - progressbar - tests -omit = - */mock/* - */nose/* - .tox/* -[paths] -source = - progressbar -[report] -fail_under = 100 -exclude_lines = - pragma: no cover - @abc.abstractmethod - def __repr__ - if self.debug: - if settings.DEBUG - raise AssertionError - raise NotImplementedError - if 0: - if __name__ == .__main__.: - if types.TYPE_CHECKING: diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index fcf5a157..00000000 --- a/.github/stale.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - in-progress - - help-wanted - - pinned - - security - - enhancement -# Label to use when marking an issue as stale -staleLabel: no-activity -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..44ccfb21 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + schedule: + - cron: "24 21 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python, javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + if: ${{ matrix.language == 'python' || matrix.language == 'javascript' }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..7101b3f5 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,17 @@ +name: Close stale issues and pull requests + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' # Run every day at midnight + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + days-before-stale: 30 + exempt-issue-labels: in-progress,help-wanted,pinned,security,enhancement + exempt-all-pr-assignees: true + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1929ed53..00000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -dist: xenial -sudo: false -language: python -python: -- '2.7' -- '3.4' -- '3.5' -- '3.6' -- '3.7' -- '3.8' -- pypy -install: -- pip install -U . -- pip install -U -r tests/requirements.txt -before_script: flake8 progressbar tests examples.py -script: -- py.test -- python examples.py -after_success: -- coveralls -- pip install codecov -- codecov -before_deploy: "python setup.py sdist bdist_wheel" -deploy: - provider: releases - api_key: - secure: DmqlCoHxPh5465T5DQgdFE7Peqy7MVF034n7t/hpV2Lf4LH9fHUo2r1dpICpBIxRuDNCXtM3PJLk59OMqCchpcAlC7VkH6dTOLpigk/IXYtlJVr3cXQUEC0gmPuFsrZ/fpWUR0PBfUD/fBA0RW64xFZ6ksfc+76tdQrKj1appz0= - file: dist/* - file_glob: true - skip_cleanup: true - on: - tags: true - repo: WoLpH/python-progressbar diff --git a/MANIFEST.in b/MANIFEST.in index eecfc0de..f387924e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ +recursive-exclude *.pyc +recursive-exclude *.pyo +recursive-exclude *.html include AUTHORS.rst include CHANGES.rst include CONTRIBUTING.rst @@ -7,6 +10,3 @@ include examples.py include requirements.txt include Makefile include pytest.ini -recursive-include tests * -recursive-exclude *.pyc -recursive-exclude *.pyo diff --git a/README.rst b/README.rst index 94b33333..434b5756 100644 --- a/README.rst +++ b/README.rst @@ -72,13 +72,19 @@ The progressbar module is very easy to use, yet very powerful. It will also automatically enable features like auto-resizing when the system supports it. ****************************************************************************** -Known issues +Security contact information ****************************************************************************** -Due to limitations in both the IDLE shell and the Jetbrains (Pycharm) shells this progressbar cannot function properly within those. +To report a security vulnerability, please use the +`Tidelift security contact `_. +Tidelift will coordinate the fix and disclosure. + +****************************************************************************** +Known issues +****************************************************************************** +- The Jetbrains (PyCharm, etc) editors work out of the box, but for more advanced features such as the `MultiBar` support you will need to enable the "Enable terminal in output console" checkbox in the Run dialog. - The IDLE editor doesn't support these types of progress bars at all: https://bugs.python.org/issue23220 -- The Jetbrains (Pycharm) editors partially work but break with fast output. As a workaround make sure you only write to either `sys.stdout` (regular print) or `sys.stderr` at the same time. If you do plan to use both, make sure you wait about ~200 milliseconds for the next output or it will break regularly. Linked issue: https://github.com/WoLpH/python-progressbar/issues/115 - Jupyter notebooks buffer `sys.stdout` which can cause mixed output. This issue can be resolved easily using: `import sys; sys.stdout.flush()`. Linked issue: https://github.com/WoLpH/python-progressbar/issues/173 ****************************************************************************** @@ -152,6 +158,38 @@ In most cases the following will work as well, as long as you initialize the logging.error('Got %d', i) time.sleep(0.2) +Multiple (threaded) progressbars +============================================================================== + +.. code:: python + + import random + import threading + import time + + import progressbar + + BARS = 5 + N = 50 + + + def do_something(bar): + for i in bar(range(N)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + # print messages at random intervals to show how extra output works + if random.random() > 0.9: + bar.print('random message for bar', bar, i) + + + with progressbar.MultiBar() as multibar: + for i in range(BARS): + # Get a progressbar + bar = multibar[f'Thread label here {i}'] + # Create a thread and pass the progressbar + threading.Thread(target=do_something, args=(bar,)).start() + Context wrapper ============================================================================== .. code:: python @@ -238,55 +276,72 @@ Bar with wide Chinese (or other multibyte) characters for i in bar(range(10)): time.sleep(0.1) -Showing multiple (threaded) independent progress bars in parallel +Showing multiple independent progress bars in parallel ============================================================================== -While this method works fine and will continue to work fine, a smarter and -fully automatic version of this is currently being made: -https://github.com/WoLpH/python-progressbar/issues/176 - .. code:: python import random import sys - import threading import time import progressbar - output_lock = threading.Lock() + BARS = 5 + N = 100 + + # Construct the list of progress bars with the `line_offset` so they draw + # below each other + bars = [] + for i in range(BARS): + bars.append( + progressbar.ProgressBar( + max_value=N, + # We add 1 to the line offset to account for the `print_fd` + line_offset=i + 1, + max_error=False, + ) + ) + # Create a file descriptor for regular printing as well + print_fd = progressbar.LineOffsetStreamWrapper(sys.stdout, 0) - class LineOffsetStreamWrapper: - UP = '\033[F' - DOWN = '\033[B' + # The progress bar updates, normally you would do something useful here + for i in range(N * BARS): + time.sleep(0.005) - def __init__(self, lines=0, stream=sys.stderr): - self.stream = stream - self.lines = lines + # Increment one of the progress bars at random + bars[random.randrange(0, BARS)].increment() - def write(self, data): - with output_lock: - self.stream.write(self.UP * self.lines) - self.stream.write(data) - self.stream.write(self.DOWN * self.lines) - self.stream.flush() + # Print a status message to the `print_fd` below the progress bars + print(f'Hi, we are at update {i+1} of {N * BARS}', file=print_fd) - def __getattr__(self, name): - return getattr(self.stream, name) + # Cleanup the bars + for bar in bars: + bar.finish() + # Add a newline to make sure the next print starts on a new line + print() - bars = [] - for i in range(5): - bars.append( - progressbar.ProgressBar( - fd=LineOffsetStreamWrapper(i), - max_value=1000, - ) - ) +****************************************************************************** - if i: - print('Reserve a line for the progressbar') +Naturally we can do this from separate threads as well: + +.. code:: python + + import random + import threading + import time + + import progressbar + + BARS = 5 + N = 100 + + # Create the bars with the given line offset + bars = [] + for line_offset in range(BARS): + bars.append(progressbar.ProgressBar(line_offset=line_offset, max_value=N)) class Worker(threading.Thread): @@ -295,10 +350,12 @@ https://github.com/WoLpH/python-progressbar/issues/176 self.bar = bar def run(self): - for i in range(1000): - time.sleep(random.random() / 100) + for i in range(N): + time.sleep(random.random() / 25) self.bar.update(i) for bar in bars: Worker(bar).start() + + print() diff --git a/docs/_theme/flask_theme_support.py b/docs/_theme/flask_theme_support.py index 555c116d..c11997c7 100644 --- a/docs/_theme/flask_theme_support.py +++ b/docs/_theme/flask_theme_support.py @@ -1,86 +1,89 @@ # flasky extensions. flasky pygments style based on tango style from pygments.style import Style -from pygments.token import Keyword, Name, Comment, String, Error, \ - Number, Operator, Generic, Whitespace, Punctuation, Other, Literal +from pygments.token import ( + Keyword, + Name, + Comment, + String, + Error, + Number, + Operator, + Generic, + Whitespace, + Punctuation, + Other, + Literal, +) class FlaskyStyle(Style): - background_color = "#f8f8f8" - default_style = "" + background_color = '#f8f8f8' + default_style = '' styles = { # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: "underline #f8f8f8", # class: 'w' - Error: "#a40000 border:#ef2929", # class: 'err' - Other: "#000000", # class 'x' - - Comment: "italic #8f5902", # class: 'c' - Comment.Preproc: "noitalic", # class: 'cp' - - Keyword: "bold #004461", # class: 'k' - Keyword.Constant: "bold #004461", # class: 'kc' - Keyword.Declaration: "bold #004461", # class: 'kd' - Keyword.Namespace: "bold #004461", # class: 'kn' - Keyword.Pseudo: "bold #004461", # class: 'kp' - Keyword.Reserved: "bold #004461", # class: 'kr' - Keyword.Type: "bold #004461", # class: 'kt' - - Operator: "#582800", # class: 'o' - Operator.Word: "bold #004461", # class: 'ow' - like keywords - - Punctuation: "bold #000000", # class: 'p' - + # Text: '', # class: '' + Whitespace: 'underline #f8f8f8', # class: 'w' + Error: '#a40000 border:#ef2929', # class: 'err' + Other: '#000000', # class 'x' + Comment: 'italic #8f5902', # class: 'c' + Comment.Preproc: 'noitalic', # class: 'cp' + Keyword: 'bold #004461', # class: 'k' + Keyword.Constant: 'bold #004461', # class: 'kc' + Keyword.Declaration: 'bold #004461', # class: 'kd' + Keyword.Namespace: 'bold #004461', # class: 'kn' + Keyword.Pseudo: 'bold #004461', # class: 'kp' + Keyword.Reserved: 'bold #004461', # class: 'kr' + Keyword.Type: 'bold #004461', # class: 'kt' + Operator: '#582800', # class: 'o' + Operator.Word: 'bold #004461', # class: 'ow' - like keywords + Punctuation: 'bold #000000', # class: 'p' # because special names such as Name.Class, Name.Function, etc. # are not recognized as such later in the parsing, we choose them # to look the same as ordinary variables. - Name: "#000000", # class: 'n' - Name.Attribute: "#c4a000", # class: 'na' - to be revised - Name.Builtin: "#004461", # class: 'nb' - Name.Builtin.Pseudo: "#3465a4", # class: 'bp' - Name.Class: "#000000", # class: 'nc' - to be revised - Name.Constant: "#000000", # class: 'no' - to be revised - Name.Decorator: "#888", # class: 'nd' - to be revised - Name.Entity: "#ce5c00", # class: 'ni' - Name.Exception: "bold #cc0000", # class: 'ne' - Name.Function: "#000000", # class: 'nf' - Name.Property: "#000000", # class: 'py' - Name.Label: "#f57900", # class: 'nl' - Name.Namespace: "#000000", # class: 'nn' - to be revised - Name.Other: "#000000", # class: 'nx' - Name.Tag: "bold #004461", # class: 'nt' - like a keyword - Name.Variable: "#000000", # class: 'nv' - to be revised - Name.Variable.Class: "#000000", # class: 'vc' - to be revised - Name.Variable.Global: "#000000", # class: 'vg' - to be revised - Name.Variable.Instance: "#000000", # class: 'vi' - to be revised - - Number: "#990000", # class: 'm' - - Literal: "#000000", # class: 'l' - Literal.Date: "#000000", # class: 'ld' - - String: "#4e9a06", # class: 's' - String.Backtick: "#4e9a06", # class: 'sb' - String.Char: "#4e9a06", # class: 'sc' - String.Doc: "italic #8f5902", # class: 'sd' - like a comment - String.Double: "#4e9a06", # class: 's2' - String.Escape: "#4e9a06", # class: 'se' - String.Heredoc: "#4e9a06", # class: 'sh' - String.Interpol: "#4e9a06", # class: 'si' - String.Other: "#4e9a06", # class: 'sx' - String.Regex: "#4e9a06", # class: 'sr' - String.Single: "#4e9a06", # class: 's1' - String.Symbol: "#4e9a06", # class: 'ss' - - Generic: "#000000", # class: 'g' - Generic.Deleted: "#a40000", # class: 'gd' - Generic.Emph: "italic #000000", # class: 'ge' - Generic.Error: "#ef2929", # class: 'gr' - Generic.Heading: "bold #000080", # class: 'gh' - Generic.Inserted: "#00A000", # class: 'gi' - Generic.Output: "#888", # class: 'go' - Generic.Prompt: "#745334", # class: 'gp' - Generic.Strong: "bold #000000", # class: 'gs' - Generic.Subheading: "bold #800080", # class: 'gu' - Generic.Traceback: "bold #a40000", # class: 'gt' + Name: '#000000', # class: 'n' + Name.Attribute: '#c4a000', # class: 'na' - to be revised + Name.Builtin: '#004461', # class: 'nb' + Name.Builtin.Pseudo: '#3465a4', # class: 'bp' + Name.Class: '#000000', # class: 'nc' - to be revised + Name.Constant: '#000000', # class: 'no' - to be revised + Name.Decorator: '#888', # class: 'nd' - to be revised + Name.Entity: '#ce5c00', # class: 'ni' + Name.Exception: 'bold #cc0000', # class: 'ne' + Name.Function: '#000000', # class: 'nf' + Name.Property: '#000000', # class: 'py' + Name.Label: '#f57900', # class: 'nl' + Name.Namespace: '#000000', # class: 'nn' - to be revised + Name.Other: '#000000', # class: 'nx' + Name.Tag: 'bold #004461', # class: 'nt' - like a keyword + Name.Variable: '#000000', # class: 'nv' - to be revised + Name.Variable.Class: '#000000', # class: 'vc' - to be revised + Name.Variable.Global: '#000000', # class: 'vg' - to be revised + Name.Variable.Instance: '#000000', # class: 'vi' - to be revised + Number: '#990000', # class: 'm' + Literal: '#000000', # class: 'l' + Literal.Date: '#000000', # class: 'ld' + String: '#4e9a06', # class: 's' + String.Backtick: '#4e9a06', # class: 'sb' + String.Char: '#4e9a06', # class: 'sc' + String.Doc: 'italic #8f5902', # class: 'sd' - like a comment + String.Double: '#4e9a06', # class: 's2' + String.Escape: '#4e9a06', # class: 'se' + String.Heredoc: '#4e9a06', # class: 'sh' + String.Interpol: '#4e9a06', # class: 'si' + String.Other: '#4e9a06', # class: 'sx' + String.Regex: '#4e9a06', # class: 'sr' + String.Single: '#4e9a06', # class: 's1' + String.Symbol: '#4e9a06', # class: 'ss' + Generic: '#000000', # class: 'g' + Generic.Deleted: '#a40000', # class: 'gd' + Generic.Emph: 'italic #000000', # class: 'ge' + Generic.Error: '#ef2929', # class: 'gr' + Generic.Heading: 'bold #000080', # class: 'gh' + Generic.Inserted: '#00A000', # class: 'gi' + Generic.Output: '#888', # class: 'go' + Generic.Prompt: '#745334', # class: 'gp' + Generic.Strong: 'bold #000000', # class: 'gs' + Generic.Subheading: 'bold #800080', # class: 'gu' + Generic.Traceback: 'bold #a40000', # class: 'gt' } diff --git a/docs/conf.py b/docs/conf.py index a7f5a618..8912b99f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) -from progressbar import __about__ as metadata +from progressbar import __about__ as metadata # noqa: E402 # -- General configuration ----------------------------------------------- @@ -61,10 +61,7 @@ # General information about the project. project = u'Progress Bar' project_slug = ''.join(project.capitalize().split()) -copyright = u'%s, %s' % ( - datetime.date.today().year, - metadata.__author__, -) +copyright = f'{datetime.date.today().year}, {metadata.__author__}' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -190,7 +187,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = '%sdoc' % project_slug +htmlhelp_basename = f'{project_slug}doc' # -- Options for LaTeX output -------------------------------------------- @@ -198,10 +195,8 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. #'preamble': '', } @@ -209,8 +204,13 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', '%s.tex' % project_slug, u'%s Documentation' % project, - metadata.__author__, 'manual'), + ( + 'index', + f'{project_slug}.tex', + f'{project} Documentation', + metadata.__author__, + 'manual', + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -239,8 +239,13 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', project_slug.lower(), u'%s Documentation' % project, - [metadata.__author__], 1) + ( + 'index', + project_slug.lower(), + f'{project} Documentation', + [metadata.__author__], + 1, + ) ] # If true, show URL addresses after external links. @@ -253,9 +258,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', project_slug, u'%s Documentation' % project, - metadata.__author__, project_slug, 'One line description of project.', - 'Miscellaneous'), + ( + 'index', + project_slug, + f'{project} Documentation', + metadata.__author__, + project_slug, + 'One line description of project.', + 'Miscellaneous', + ) ] # Documents to append as an appendix to all manuals. @@ -303,7 +314,7 @@ # The format is a list of tuples containing the path and title. # epub_pre_files = [] -# HTML files shat should be inserted after the pages created by sphinx. +# HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_post_files = [] diff --git a/docs/progressbar.multi.rst b/docs/progressbar.multi.rst new file mode 100644 index 00000000..5d8b85fd --- /dev/null +++ b/docs/progressbar.multi.rst @@ -0,0 +1,7 @@ +progressbar.multi module +======================== + +.. automodule:: progressbar.multi + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.rst b/docs/progressbar.rst new file mode 100644 index 00000000..674f6b64 --- /dev/null +++ b/docs/progressbar.rst @@ -0,0 +1,31 @@ +progressbar package +=================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.bar + progressbar.base + progressbar.multi + progressbar.shortcuts + progressbar.utils + progressbar.widgets + +Module contents +--------------- + +.. automodule:: progressbar + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.base.rst b/docs/progressbar.terminal.base.rst new file mode 100644 index 00000000..8114b8cf --- /dev/null +++ b/docs/progressbar.terminal.base.rst @@ -0,0 +1,7 @@ +progressbar.terminal.base module +================================ + +.. automodule:: progressbar.terminal.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.colors.rst b/docs/progressbar.terminal.colors.rst new file mode 100644 index 00000000..d03706f7 --- /dev/null +++ b/docs/progressbar.terminal.colors.rst @@ -0,0 +1,7 @@ +progressbar.terminal.colors module +================================== + +.. automodule:: progressbar.terminal.colors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.os_specific.posix.rst b/docs/progressbar.terminal.os_specific.posix.rst new file mode 100644 index 00000000..7d1ec491 --- /dev/null +++ b/docs/progressbar.terminal.os_specific.posix.rst @@ -0,0 +1,7 @@ +progressbar.terminal.os\_specific.posix module +============================================== + +.. automodule:: progressbar.terminal.os_specific.posix + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.os_specific.rst b/docs/progressbar.terminal.os_specific.rst new file mode 100644 index 00000000..456ef9cc --- /dev/null +++ b/docs/progressbar.terminal.os_specific.rst @@ -0,0 +1,19 @@ +progressbar.terminal.os\_specific package +========================================= + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.os_specific.posix + progressbar.terminal.os_specific.windows + +Module contents +--------------- + +.. automodule:: progressbar.terminal.os_specific + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.os_specific.windows.rst b/docs/progressbar.terminal.os_specific.windows.rst new file mode 100644 index 00000000..0595e93a --- /dev/null +++ b/docs/progressbar.terminal.os_specific.windows.rst @@ -0,0 +1,7 @@ +progressbar.terminal.os\_specific.windows module +================================================ + +.. automodule:: progressbar.terminal.os_specific.windows + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.rst b/docs/progressbar.terminal.rst new file mode 100644 index 00000000..dba09353 --- /dev/null +++ b/docs/progressbar.terminal.rst @@ -0,0 +1,28 @@ +progressbar.terminal package +============================ + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.os_specific + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + progressbar.terminal.base + progressbar.terminal.colors + progressbar.terminal.stream + +Module contents +--------------- + +.. automodule:: progressbar.terminal + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/progressbar.terminal.stream.rst b/docs/progressbar.terminal.stream.rst new file mode 100644 index 00000000..2bb3b355 --- /dev/null +++ b/docs/progressbar.terminal.stream.rst @@ -0,0 +1,7 @@ +progressbar.terminal.stream module +================================== + +.. automodule:: progressbar.terminal.stream + :members: + :undoc-members: + :show-inheritance: diff --git a/examples.py b/examples.py index 1c033839..8b7247c9 100644 --- a/examples.py +++ b/examples.py @@ -5,10 +5,11 @@ import random import sys import time +import typing import progressbar -examples = [] +examples: typing.List[typing.Callable[[typing.Any], typing.Any]] = [] def example(fn): @@ -31,7 +32,7 @@ def wrapped(*args, **kwargs): @example def fast_example(): - ''' Updates bar really quickly to cause flickering ''' + '''Updates bar really quickly to cause flickering''' with progressbar.ProgressBar(widgets=[progressbar.Bar()]) as bar: for i in range(100): bar.update(int(i / 10), force=True) @@ -55,6 +56,25 @@ def templated_shortcut_example(): time.sleep(0.1) +@example +def job_status_example(): + with progressbar.ProgressBar( + redirect_stdout=True, + widgets=[progressbar.widgets.JobStatusBar('status')], + ) as bar: + for i in range(30): + print('random', random.random()) + # Roughly 1/3 probability for each status ;) + # Yes... probability is confusing at times + if random.random() > 0.66: + bar.increment(status=True) + elif random.random() > 0.5: + bar.increment(status=False) + else: + bar.increment(status=None) + time.sleep(0.1) + + @example def with_example_stdout_redirection(): with progressbar.ProgressBar(max_value=10, redirect_stdout=True) as p: @@ -96,12 +116,14 @@ def color_bar_example(): def color_bar_animated_marker_example(): widgets = [ # Colored animated marker with colored fill: - progressbar.Bar(marker=progressbar.AnimatedMarker( - fill='x', - # fill='█', - fill_wrap='\x1b[32m{}\x1b[39m', - marker_wrap='\x1b[31m{}\x1b[39m', - )), + progressbar.Bar( + marker=progressbar.AnimatedMarker( + fill='x', + # fill='█', + fill_wrap='\x1b[32m{}\x1b[39m', + marker_wrap='\x1b[31m{}\x1b[39m', + ) + ), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=10).start() for i in range(10): @@ -117,7 +139,7 @@ def multi_range_bar_example(): '\x1b[32m█\x1b[39m', # Done '\x1b[33m#\x1b[39m', # Processing '\x1b[31m.\x1b[39m', # Scheduling - ' ' # Not started + ' ', # Not started ] widgets = [progressbar.MultiRangeBar("amounts", markers=markers)] amounts = [0] * (len(markers) - 1) + [25] @@ -150,7 +172,8 @@ def multi_progress_bar_example(left=True): widgets = [ progressbar.Percentage(), - ' ', progressbar.MultiProgressBar('jobs', fill_left=left), + ' ', + progressbar.MultiProgressBar('jobs', fill_left=left), ] max_value = sum([total for progress, total in jobs]) @@ -202,10 +225,14 @@ def percentage_label_bar_example(): @example def file_transfer_example(): widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() for i in range(100): @@ -220,16 +247,20 @@ class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): ''' It's bigger between 45 and 80 percent ''' + def update(self, bar): if 45 < bar.percentage() < 80: return 'Bigger Now ' + progressbar.FileTransferSpeed.update( - self, bar) + self, bar + ) else: return progressbar.FileTransferSpeed.update(self, bar) widgets = [ CrazyFileTransferSpeed(), - ' <<<', progressbar.Bar(), '>>> ', + ' <<<', + progressbar.Bar(), + '>>> ', progressbar.Percentage(), ' ', progressbar.ETA(), @@ -246,8 +277,10 @@ def update(self, bar): @example def double_bar_example(): widgets = [ - progressbar.Bar('>'), ' ', - progressbar.ETA(), ' ', + progressbar.Bar('>'), + ' ', + progressbar.ETA(), + ' ', progressbar.ReverseBar('<'), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=1000).start() @@ -261,10 +294,14 @@ def double_bar_example(): @example def basic_file_transfer(): widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker='0', left='[', right=']'), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker='0', left='[', right=']'), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=500) bar.start() @@ -315,26 +352,34 @@ def progress_with_unavailable_max(): @example def animated_marker(): bar = progressbar.ProgressBar( - widgets=['Working: ', progressbar.AnimatedMarker()]) + widgets=['Working: ', progressbar.AnimatedMarker()] + ) for i in bar((i for i in range(5))): time.sleep(0.1) @example def filling_bar_animated_marker(): - bar = progressbar.ProgressBar(widgets=[ - progressbar.Bar( - marker=progressbar.AnimatedMarker(fill='#'), - ), - ]) + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Bar( + marker=progressbar.AnimatedMarker(fill='#'), + ), + ] + ) for i in bar(range(15)): time.sleep(0.1) @example def counter_and_timer(): - widgets = ['Processed: ', progressbar.Counter('Counter: %(value)05d'), - ' lines (', progressbar.Timer(), ')'] + widgets = [ + 'Processed: ', + progressbar.Counter('Counter: %(value)05d'), + ' lines (', + progressbar.Timer(), + ')', + ] bar = progressbar.ProgressBar(widgets=widgets) for i in bar((i for i in range(15))): time.sleep(0.1) @@ -342,8 +387,9 @@ def counter_and_timer(): @example def format_label(): - widgets = [progressbar.FormatLabel( - 'Processed: %(value)d lines (in: %(elapsed)s)')] + widgets = [ + progressbar.FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)') + ] bar = progressbar.ProgressBar(widgets=widgets) for i in bar((i for i in range(15))): time.sleep(0.1) @@ -406,8 +452,10 @@ def format_label_bouncer(): @example def format_label_rotating_bouncer(): - widgets = [progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), - progressbar.BouncingBar(marker=progressbar.RotatingMarker())] + widgets = [ + progressbar.FormatLabel('Animated Bouncer: value %(value)d - '), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ] bar = progressbar.ProgressBar(widgets=widgets) for i in bar((i for i in range(18))): @@ -416,8 +464,9 @@ def format_label_rotating_bouncer(): @example def with_right_justify(): - with progressbar.ProgressBar(max_value=10, term_width=20, - left_justify=False) as progress: + with progressbar.ProgressBar( + max_value=10, term_width=20, left_justify=False + ) as progress: assert progress.term_width is not None for i in range(10): progress.update(i) @@ -467,16 +516,21 @@ def negative_maximum(): @example def rotating_bouncing_marker(): widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker())] - with progressbar.ProgressBar(widgets=widgets, max_value=20, - term_width=10) as progress: + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): time.sleep(0.1) progress.update(i) - widgets = [progressbar.BouncingBar(marker=progressbar.RotatingMarker(), - fill_left=False)] - with progressbar.ProgressBar(widgets=widgets, max_value=20, - term_width=10) as progress: + widgets = [ + progressbar.BouncingBar( + marker=progressbar.RotatingMarker(), fill_left=False + ) + ] + with progressbar.ProgressBar( + widgets=widgets, max_value=20, term_width=10 + ) as progress: for i in range(20): time.sleep(0.1) progress.update(i) @@ -484,10 +538,13 @@ def rotating_bouncing_marker(): @example def incrementing_bar(): - bar = progressbar.ProgressBar(widgets=[ - progressbar.Percentage(), - progressbar.Bar(), - ], max_value=10).start() + bar = progressbar.ProgressBar( + widgets=[ + progressbar.Percentage(), + progressbar.Bar(), + ], + max_value=10, + ).start() for i in range(10): # do something time.sleep(0.1) @@ -498,13 +555,18 @@ def incrementing_bar(): @example def increment_bar_with_output_redirection(): widgets = [ - 'Test: ', progressbar.Percentage(), - ' ', progressbar.Bar(marker=progressbar.RotatingMarker()), - ' ', progressbar.ETA(), - ' ', progressbar.FileTransferSpeed(), + 'Test: ', + progressbar.Percentage(), + ' ', + progressbar.Bar(marker=progressbar.RotatingMarker()), + ' ', + progressbar.ETA(), + ' ', + progressbar.FileTransferSpeed(), ] - bar = progressbar.ProgressBar(widgets=widgets, max_value=100, - redirect_stdout=True).start() + bar = progressbar.ProgressBar( + widgets=widgets, max_value=100, redirect_stdout=True + ).start() for i in range(10): # do something time.sleep(0.01) @@ -517,12 +579,18 @@ def increment_bar_with_output_redirection(): def eta_types_demonstration(): widgets = [ progressbar.Percentage(), - ' ETA: ', progressbar.ETA(), - ' Adaptive ETA: ', progressbar.AdaptiveETA(), - ' Absolute ETA: ', progressbar.AbsoluteETA(), - ' Transfer Speed: ', progressbar.FileTransferSpeed(), - ' Adaptive Transfer Speed: ', progressbar.AdaptiveTransferSpeed(), - ' ', progressbar.Bar(), + ' ETA: ', + progressbar.ETA(), + ' Adaptive ETA: ', + progressbar.AdaptiveETA(), + ' Absolute ETA: ', + progressbar.AbsoluteETA(), + ' Transfer Speed: ', + progressbar.FileTransferSpeed(), + ' Adaptive Transfer Speed: ', + progressbar.AdaptiveTransferSpeed(), + ' ', + progressbar.Bar(), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=500) bar.start() @@ -540,10 +608,14 @@ def eta_types_demonstration(): @example def adaptive_eta_without_value_change(): # Testing progressbar.AdaptiveETA when the value doesn't actually change - bar = progressbar.ProgressBar(widgets=[ - progressbar.AdaptiveETA(), - progressbar.AdaptiveTransferSpeed(), - ], max_value=2, poll_interval=0.0001) + bar = progressbar.ProgressBar( + widgets=[ + progressbar.AdaptiveETA(), + progressbar.AdaptiveTransferSpeed(), + ], + max_value=2, + poll_interval=0.0001, + ) bar.start() for i in range(100): bar.update(1) @@ -564,10 +636,14 @@ def iterator_with_max_value(): @example def eta(): widgets = [ - 'Test: ', progressbar.Percentage(), - ' | ETA: ', progressbar.ETA(), - ' | AbsoluteETA: ', progressbar.AbsoluteETA(), - ' | AdaptiveETA: ', progressbar.AdaptiveETA(), + 'Test: ', + progressbar.Percentage(), + ' | ETA: ', + progressbar.ETA(), + ' | AbsoluteETA: ', + progressbar.AbsoluteETA(), + ' | AdaptiveETA: ', + progressbar.AdaptiveETA(), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=50).start() for i in range(50): @@ -622,14 +698,16 @@ def user_variables(): num_subtasks = sum(len(x) for x in tasks.values()) with progressbar.ProgressBar( - prefix='{variables.task} >> {variables.subtask}', - variables={'task': '--', 'subtask': '--'}, - max_value=10 * num_subtasks) as bar: + prefix='{variables.task} >> {variables.subtask}', + variables={'task': '--', 'subtask': '--'}, + max_value=10 * num_subtasks, + ) as bar: for tasks_name, subtasks in tasks.items(): for subtask_name in subtasks: for i in range(10): - bar.update(bar.value + 1, task=tasks_name, - subtask=subtask_name) + bar.update( + bar.value + 1, task=tasks_name, subtask=subtask_name + ) time.sleep(0.1) @@ -643,11 +721,13 @@ def format_custom_text(): ), ) - bar = progressbar.ProgressBar(widgets=[ - format_custom_text, - ' :: ', - progressbar.Percentage(), - ]) + bar = progressbar.ProgressBar( + widgets=[ + format_custom_text, + ' :: ', + progressbar.Percentage(), + ] + ) for i in bar(range(25)): format_custom_text.update_mapping(eggs=i * 2) time.sleep(0.1) @@ -666,9 +746,13 @@ def gen(): for x in range(200): yield None - widgets = [progressbar.AdaptiveETA(), ' ', - progressbar.ETA(), ' ', - progressbar.Timer()] + widgets = [ + progressbar.AdaptiveETA(), + ' ', + progressbar.ETA(), + ' ', + progressbar.Timer(), + ] bar = progressbar.ProgressBar(widgets=widgets) for i in bar(gen()): @@ -681,9 +765,14 @@ def gen(): for x in range(200): yield None - widgets = [progressbar.Counter(), ' ', - progressbar.Percentage(), ' ', - progressbar.SimpleProgress(), ' '] + widgets = [ + progressbar.Counter(), + ' ', + progressbar.Percentage(), + ' ', + progressbar.SimpleProgress(), + ' ', + ] bar = progressbar.ProgressBar(widgets=widgets) for i in bar(gen()): @@ -693,7 +782,6 @@ def gen(): def test(*tests): if tests: for example in examples: - for test in tests: if test in example.__name__: example() @@ -710,4 +798,4 @@ def test(*tests): try: test(*sys.argv[1:]) except KeyboardInterrupt: - sys.stdout('\nQuitting examples.\n') + sys.stdout.write('\nQuitting examples.\n') diff --git a/progressbar/__about__.py b/progressbar/__about__.py index a5d57b2e..5760b8d8 100644 --- a/progressbar/__about__.py +++ b/progressbar/__about__.py @@ -18,10 +18,10 @@ ''' A Python Progressbar library to provide visual (yet text based) progress to long running operations. -'''.strip().split() +'''.strip().split(), ) __email__ = 'wolph@wol.ph' -__version__ = '4.3b.0' +__version__ = '4.3.0' __license__ = 'BSD' __copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' __url__ = 'https://github.com/WoLpH/python-progressbar' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 33d7c719..43824995 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -1,40 +1,41 @@ from datetime import date -from .__about__ import __author__ -from .__about__ import __version__ -from .bar import DataTransferBar -from .bar import NullBar -from .bar import ProgressBar +from .__about__ import __author__, __version__ +from .bar import DataTransferBar, NullBar, ProgressBar from .base import UnknownLength +from .multi import MultiBar, SortKey from .shortcuts import progressbar -from .utils import len_color -from .utils import streams -from .widgets import AbsoluteETA -from .widgets import AdaptiveETA -from .widgets import AdaptiveTransferSpeed -from .widgets import AnimatedMarker -from .widgets import Bar -from .widgets import BouncingBar -from .widgets import Counter -from .widgets import CurrentTime -from .widgets import DataSize -from .widgets import DynamicMessage -from .widgets import ETA -from .widgets import FileTransferSpeed -from .widgets import FormatCustomText -from .widgets import FormatLabel -from .widgets import FormatLabelBar -from .widgets import GranularBar -from .widgets import MultiProgressBar -from .widgets import MultiRangeBar -from .widgets import Percentage -from .widgets import PercentageLabelBar -from .widgets import ReverseBar -from .widgets import RotatingMarker -from .widgets import SimpleProgress -from .widgets import Timer -from .widgets import Variable -from .widgets import VariableMixin +from .terminal.stream import LineOffsetStreamWrapper +from .utils import len_color, streams +from .widgets import ( + ETA, + AbsoluteETA, + AdaptiveETA, + AdaptiveTransferSpeed, + AnimatedMarker, + Bar, + BouncingBar, + Counter, + CurrentTime, + DataSize, + DynamicMessage, + FileTransferSpeed, + FormatCustomText, + FormatLabel, + FormatLabelBar, + GranularBar, + JobStatusBar, + MultiProgressBar, + MultiRangeBar, + Percentage, + PercentageLabelBar, + ReverseBar, + RotatingMarker, + SimpleProgress, + Timer, + Variable, + VariableMixin, +) __date__ = str(date.today()) __all__ = [ @@ -73,4 +74,8 @@ 'NullBar', '__author__', '__version__', + 'LineOffsetStreamWrapper', + 'MultiBar', + 'SortKey', + 'JobStatusBar', ] diff --git a/progressbar/bar.py b/progressbar/bar.py index f1077cb9..d7221001 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1,7 +1,10 @@ from __future__ import annotations import abc +import contextlib +import itertools import logging +import math import os import sys import time @@ -9,11 +12,13 @@ import warnings from copy import deepcopy from datetime import datetime -from typing import Type -import math from python_utils import converters, types +import progressbar.env +import progressbar.terminal +import progressbar.terminal.stream + from . import ( base, utils, @@ -25,7 +30,9 @@ # float also accepts integers and longs but we don't want an explicit union # due to type checking complexity -T = float +NumberT = float + +T = types.TypeVar('T') class ProgressBarMixinBase(abc.ABC): @@ -37,7 +44,7 @@ class ProgressBarMixinBase(abc.ABC): #: fall back to 80 if auto detection is not possible. term_width: int = 80 #: The widgets to render, defaults to the result of `default_widget()` - widgets: types.List[widgets_module.WidgetBase] + widgets: types.MutableSequence[widgets_module.WidgetBase | str] #: When going beyond the max_value, raise an error if True or silently #: ignore otherwise max_error: bool @@ -63,13 +70,22 @@ class ProgressBarMixinBase(abc.ABC): #: no updates min_poll_interval: float + #: Deprecated: The number of intervals that can fit on the screen with a + #: minimum of 100 + num_intervals: int = 0 + #: Deprecated: The `next_update` is kept for compatibility with external + #: libs: https://github.com/WoLpH/python-progressbar/issues/207 + next_update: int = 0 + #: Current progress (min_value <= value <= max_value) - value: T + value: NumberT + #: Previous progress value + previous_value: types.Optional[NumberT] #: The minimum/start value for the progress bar - min_value: T + min_value: NumberT #: Maximum (and final) value. Beyond this value an error will be raised #: unless the `max_error` parameter is `False`. - max_value: T | types.Type[base.UnknownLength] + max_value: NumberT | types.Type[base.UnknownLength] #: The time the progressbar reached `max_value` or when `finish()` was #: called. end_time: types.Optional[datetime] @@ -97,93 +113,165 @@ def set_last_update_time(self, value: types.Optional[datetime]): last_update_time = property(get_last_update_time, set_last_update_time) - def __init__(self, **kwargs): + def __init__(self, **kwargs): # noqa: B027 pass def start(self, **kwargs): self._started = True - def update(self, value=None): + def update(self, value=None): # noqa: B027 pass - def finish(self) -> None: # pragma: no cover + def finish(self): # pragma: no cover self._finished = True def __del__(self): if not self._finished and self._started: # pragma: no cover - try: - self.finish() - except Exception: - # Never raise during cleanup. We're too late now - logging.debug( - 'Exception raised during ProgressBar cleanup', - exc_info=True, - ) + self.finish() def __getstate__(self): return self.__dict__ - def data(self) -> types.Dict[str, types.Any]: + def data(self) -> types.Dict[str, types.Any]: # pragma: no cover raise NotImplementedError() + def started(self) -> bool: + return self._finished or self._started + + def finished(self) -> bool: + return self._finished + class ProgressBarBase(types.Iterable, ProgressBarMixinBase): - pass + _index_counter = itertools.count() + index: int = -1 + label: str = '' + + def __init__(self, **kwargs): + self.index = next(self._index_counter) + super().__init__(**kwargs) + + def __repr__(self): + label = f': {self.label}' if self.label else '' + return f'<{self.__class__.__name__}#{self.index}{label}>' class DefaultFdMixin(ProgressBarMixinBase): # The file descriptor to write to. Defaults to `sys.stderr` - fd: base.IO = sys.stderr + fd: base.TextIO = sys.stderr #: Set the terminal to be ANSI compatible. If a terminal is ANSI #: compatible we will automatically enable `colors` and disable #: `line_breaks`. is_ansi_terminal: bool = False + #: Whether the file descriptor is a terminal or not. This is used to + #: determine whether to use ANSI escape codes or not. + is_terminal: bool #: Whether to print line breaks. This is useful for logging the #: progressbar. When disabled the current line is overwritten. line_breaks: bool = True - #: Enable or disable colors. Defaults to auto detection - enable_colors: bool = False + #: Specify the type and number of colors to support. Defaults to auto + #: detection based on the file descriptor type (i.e. interactive terminal) + #: environment variables such as `COLORTERM` and `TERM`. Color output can + #: be forced in non-interactive terminals using the + #: `PROGRESSBAR_ENABLE_COLORS` environment variable which can also be used + #: to force a specific number of colors by specifying `24bit`, `256` or + #: `16`. + #: For true (24 bit/16M) color support you can use `COLORTERM=truecolor`. + #: For 256 color support you can use `TERM=xterm-256color`. + #: For 16 colorsupport you can use `TERM=xterm`. + enable_colors: progressbar.env.ColorSupport | bool | None = ( + progressbar.env.COLOR_SUPPORT + ) def __init__( self, - fd: base.IO = sys.stderr, + fd: base.TextIO = sys.stderr, is_terminal: bool | None = None, line_breaks: bool | None = None, - enable_colors: bool | None = None, + enable_colors: progressbar.env.ColorSupport | None = None, + line_offset: int = 0, **kwargs, ): if fd is sys.stdout: fd = utils.streams.original_stdout - elif fd is sys.stderr: fd = utils.streams.original_stderr + fd = self._apply_line_offset(fd, line_offset) self.fd = fd - self.is_ansi_terminal = utils.is_ansi_terminal(fd) + self.is_ansi_terminal = progressbar.env.is_ansi_terminal(fd) + self.is_terminal = self._determine_is_terminal(fd, is_terminal) + self.line_breaks = self._determine_line_breaks(line_breaks) + self.enable_colors = self._determine_enable_colors(enable_colors) - # Check if this is an interactive terminal - self.is_terminal = utils.is_terminal( - fd, is_terminal or self.is_ansi_terminal - ) + super().__init__(**kwargs) - # Check if it should overwrite the current line (suitable for - # iteractive terminals) or write line breaks (suitable for log files) + def _apply_line_offset( + self, + fd: base.TextIO, + line_offset: int, + ) -> base.TextIO: + if line_offset: + return progressbar.terminal.stream.LineOffsetStreamWrapper( + line_offset, + fd, + ) + else: + return fd + + def _determine_is_terminal( + self, + fd: base.TextIO, + is_terminal: bool | None, + ) -> bool: + if is_terminal is not None: + return progressbar.env.is_terminal(fd, is_terminal) + else: + return progressbar.env.is_ansi_terminal(fd) + + def _determine_line_breaks(self, line_breaks: bool | None) -> bool: if line_breaks is None: - line_breaks = utils.env_flag( - 'PROGRESSBAR_LINE_BREAKS', not self.is_terminal + return progressbar.env.env_flag( + 'PROGRESSBAR_LINE_BREAKS', + not self.is_terminal, ) - self.line_breaks = bool(line_breaks) + else: + return bool(line_breaks) - # Check if ANSI escape characters are enabled (suitable for iteractive - # terminals), or should be stripped off (suitable for log files) + def _determine_enable_colors( + self, + enable_colors: progressbar.env.ColorSupport | None, + ) -> progressbar.env.ColorSupport: if enable_colors is None: - enable_colors = utils.env_flag( - 'PROGRESSBAR_ENABLE_COLORS', self.is_ansi_terminal + colors = ( + progressbar.env.env_flag('PROGRESSBAR_ENABLE_COLORS'), + progressbar.env.env_flag('FORCE_COLOR'), + self.is_ansi_terminal, ) - self.enable_colors = bool(enable_colors) - - ProgressBarMixinBase.__init__(self, **kwargs) + for color_enabled in colors: + if color_enabled is not None: + if color_enabled: + enable_colors = progressbar.env.COLOR_SUPPORT + else: + enable_colors = progressbar.env.ColorSupport.NONE + break + else: # pragma: no cover + # This scenario should never occur because `is_ansi_terminal` + # should always be `True` or `False` + raise ValueError('Unable to determine color support') + + elif enable_colors is True: + enable_colors = progressbar.env.ColorSupport.XTERM_256 + elif enable_colors is False: + enable_colors = progressbar.env.ColorSupport.NONE + elif not isinstance(enable_colors, progressbar.env.ColorSupport): + raise ValueError(f'Invalid color support value: {enable_colors}') + + return enable_colors + + def print(self, *args: types.Any, **kwargs: types.Any) -> None: + print(*args, file=self.fd, **kwargs) def update(self, *args: types.Any, **kwargs: types.Any) -> None: ProgressBarMixinBase.update(self, *args, **kwargs) @@ -192,18 +280,17 @@ def update(self, *args: types.Any, **kwargs: types.Any) -> None: if not self.enable_colors: line = utils.no_color(line) - if self.line_breaks: - line = line.rstrip() + '\n' - else: - line = '\r' + line + line = line.rstrip() + '\n' if self.line_breaks else '\r' + line try: # pragma: no cover self.fd.write(line) except UnicodeEncodeError: # pragma: no cover - self.fd.write(line.encode('ascii', 'replace')) + self.fd.write(types.cast(str, line.encode('ascii', 'replace'))) def finish( - self, *args: types.Any, **kwargs: types.Any + self, + *args: types.Any, + **kwargs: types.Any, ) -> None: # pragma: no cover if self._finished: return @@ -217,8 +304,7 @@ def finish( self.fd.flush() def _format_line(self): - 'Joins the widgets and justifies the line' - + 'Joins the widgets and justifies the line.' widgets = ''.join(self._to_unicode(self._format_widgets())) if self.left_justify: @@ -234,7 +320,8 @@ def _format_widgets(self): for index, widget in enumerate(self.widgets): if isinstance( - widget, widgets.WidgetBase + widget, + widgets.WidgetBase, ) and not widget.check_size(self): continue elif isinstance(widget, widgets.AutoWidthWidgetBase): @@ -275,31 +362,26 @@ def __init__(self, term_width: int | None = None, **kwargs): if term_width: self.term_width = term_width else: # pragma: no cover - try: + with contextlib.suppress(Exception): self._handle_resize() import signal self._prev_handle = signal.getsignal(signal.SIGWINCH) signal.signal(signal.SIGWINCH, self._handle_resize) self.signal_set = True - except Exception: - pass def _handle_resize(self, signum=None, frame=None): 'Tries to catch resize signals sent from the terminal.' - w, h = utils.get_terminal_size() self.term_width = w def finish(self): # pragma: no cover ProgressBarMixinBase.finish(self) if self.signal_set: - try: + with contextlib.suppress(Exception): import signal signal.signal(signal.SIGWINCH, self._prev_handle) - except Exception: # pragma no cover - pass class StdRedirectMixin(DefaultFdMixin): @@ -394,6 +476,9 @@ class ProgressBar( from a label using `format='{variables.my_var}'`. These values can be updated using `bar.update(my_var='newValue')` This can also be used to set initial values for variables' widgets + line_offset (int): The number of lines to offset the progressbar from + your current line. This is useful if you have other output or + multiple progressbars A common way of using it is like: @@ -432,18 +517,21 @@ class ProgressBar( _iterable: types.Optional[types.Iterator] - _DEFAULT_MAXVAL: Type[base.UnknownLength] = base.UnknownLength + _DEFAULT_MAXVAL: type[base.UnknownLength] = base.UnknownLength # update every 50 milliseconds (up to a 20 times per second) _MINIMUM_UPDATE_INTERVAL: float = 0.050 _last_update_time: types.Optional[float] = None + paused: bool = False def __init__( self, - min_value: T = 0, - max_value: T | types.Type[base.UnknownLength] | None = None, - widgets: types.Optional[types.List[widgets_module.WidgetBase]] = None, + min_value: NumberT = 0, + max_value: NumberT | types.Type[base.UnknownLength] | None = None, + widgets: types.Optional[ + types.Sequence[widgets_module.WidgetBase | str] + ] = None, left_justify: bool = True, - initial_value: T = 0, + initial_value: NumberT = 0, poll_interval: types.Optional[float] = None, widget_kwargs: types.Optional[types.Dict[str, types.Any]] = None, custom_len: types.Callable[[str], int] = utils.len_color, @@ -453,10 +541,8 @@ def __init__( variables=None, min_poll_interval=None, **kwargs, - ): - ''' - Initializes a progress bar with sane defaults - ''' + ): # sourcery skip: low-code-quality + '''Initializes a progress bar with sane defaults.''' StdRedirectMixin.__init__(self, **kwargs) ResizableMixin.__init__(self, **kwargs) ProgressBarBase.__init__(self, **kwargs) @@ -465,6 +551,7 @@ def __init__( 'The usage of `maxval` is deprecated, please use ' '`max_value` instead', DeprecationWarning, + stacklevel=1, ) max_value = kwargs.get('maxval') @@ -473,16 +560,14 @@ def __init__( 'The usage of `poll` is deprecated, please use ' '`poll_interval` instead', DeprecationWarning, + stacklevel=1, ) poll_interval = kwargs.get('poll') - if max_value: - # mypy doesn't understand that a boolean check excludes - # `UnknownLength` - if min_value > max_value: # type: ignore - raise ValueError( - 'Max value needs to be bigger than the min ' 'value' - ) + if max_value and min_value > types.cast(NumberT, max_value): + raise ValueError( + 'Max value needs to be bigger than the min value', + ) self.min_value = min_value # Legacy issue, `max_value` can be `None` before execution. After # that it either has a value or is `UnknownLength` @@ -516,7 +601,8 @@ def __init__( # (downloading a 1GiB file for example) this adds up. poll_interval = utils.deltas_to_seconds(poll_interval, default=None) min_poll_interval = utils.deltas_to_seconds( - min_poll_interval, default=None + min_poll_interval, + default=None, ) self._MINIMUM_UPDATE_INTERVAL = ( utils.deltas_to_seconds(self._MINIMUM_UPDATE_INTERVAL) @@ -535,9 +621,11 @@ def __init__( # A dictionary of names that can be used by Variable and FormatWidget self.variables = utils.AttributeDict(variables or {}) for widget in self.widgets: - if isinstance(widget, widgets_module.VariableMixin): - if widget.name not in self.variables: - self.variables[widget.name] = None + if ( + isinstance(widget, widgets_module.VariableMixin) + and widget.name not in self.variables + ): + self.variables[widget.name] = None @property def dynamic_messages(self): # pragma: no cover @@ -550,7 +638,7 @@ def dynamic_messages(self, value): # pragma: no cover def init(self): ''' (re)initialize values to original state so the progressbar can be - used (again) + used (again). ''' self.previous_value = None self.last_update_time = None @@ -561,8 +649,8 @@ def init(self): self._last_update_timer = timeit.default_timer() @property - def percentage(self): - '''Return current percentage, returns None if no max_value is given + def percentage(self) -> float | None: + '''Return current percentage, returns None if no max_value is given. >>> progress = ProgressBar() >>> progress.max_value = 10 @@ -628,7 +716,7 @@ def data(self) -> types.Dict[str, types.Any]: is available - `dynamic_messages`: Deprecated, use `variables` instead. - `variables`: Dictionary of user-defined variables for the - :py:class:`~progressbar.widgets.Variable`'s + :py:class:`~progressbar.widgets.Variable`'s. ''' self._last_update_time = time.time() @@ -680,7 +768,7 @@ def default_widgets(self): widgets.Percentage(**self.widget_kwargs), ' ', widgets.SimpleProgress( - format='(%s)' % widgets.SimpleProgress.DEFAULT_FORMAT, + format=f'({widgets.SimpleProgress.DEFAULT_FORMAT})', **self.widget_kwargs, ), ' ', @@ -702,7 +790,7 @@ def default_widgets(self): ] def __call__(self, iterable, max_value=None): - 'Use a ProgressBar to iterate through an iterable' + 'Use a ProgressBar to iterate through an iterable.' if max_value is not None: self.max_value = max_value elif self.max_value is None: @@ -729,13 +817,14 @@ def __next__(self): else: self.update(self.value + 1) - return value except StopIteration: self.finish() raise except GeneratorExit: # pragma: no cover self.finish(dirty=True) raise + else: + return value def __exit__(self, exc_type, exc_value, traceback): self.finish(dirty=bool(exc_type)) @@ -757,6 +846,8 @@ def increment(self, value=1, *args, **kwargs): def _needs_update(self): 'Returns whether the ProgressBar should redraw the line.' + if self.paused: + return False delta = timeit.default_timer() - self._last_update_timer if delta < self.min_poll_interval: # Prevent updating too often @@ -768,16 +859,12 @@ def _needs_update(self): # Update if value increment is not large enough to # add more bars to progressbar (according to current # terminal width) - try: + with contextlib.suppress(Exception): divisor: float = self.max_value / self.term_width # type: ignore value_divisor = self.value // divisor # type: ignore pvalue_divisor = self.previous_value // divisor # type: ignore if value_divisor != pvalue_divisor: return True - except Exception: - # ignore any division errors - pass - # No need to redraw yet return False @@ -785,52 +872,61 @@ def update(self, value=None, force=False, **kwargs): 'Updates the ProgressBar to a new value.' if self.start_time is None: self.start() - return self.update(value, force=force, **kwargs) - if value is not None and value is not base.UnknownLength: + if ( + value is not None + and value is not base.UnknownLength + and isinstance(value, int) + ): if self.max_value is base.UnknownLength: # Can't compare against unknown lengths so just update pass - elif self.min_value <= value <= self.max_value: # pragma: no cover - # Correct value, let's accept - pass - elif self.max_error: + elif self.min_value > value: # type: ignore raise ValueError( - 'Value %s is out of range, should be between %s and %s' - % (value, self.min_value, self.max_value) + f'Value {value} is too small. Should be ' + f'between {self.min_value} and {self.max_value}', ) - else: - self.max_value = value + elif self.max_value < value: # type: ignore + if self.max_error: + raise ValueError( + f'Value {value} is too large. Should be between ' + f'{self.min_value} and {self.max_value}', + ) + else: + value = self.max_value self.previous_value = self.value - self.value = value + self.value = value # type: ignore # Save the updated values for dynamic messages + variables_changed = self._update_variables(kwargs) + + if self._needs_update() or variables_changed or force: + self._update_parents(value) + + def _update_variables(self, kwargs): variables_changed = False - for key in kwargs: + for key, value_ in kwargs.items(): if key not in self.variables: raise TypeError( - 'update() got an unexpected keyword ' - + 'argument {0!r}'.format(key) + 'update() got an unexpected variable name as argument ' + '{key!r}', ) - elif self.variables[key] != kwargs[key]: + elif self.variables[key] != value_: self.variables[key] = kwargs[key] variables_changed = True + return variables_changed - if self._needs_update() or variables_changed or force: - self.updates += 1 - ResizableMixin.update(self, value=value) - ProgressBarBase.update(self, value=value) - StdRedirectMixin.update(self, value=value) + def _update_parents(self, value): + self.updates += 1 + ResizableMixin.update(self, value=value) + ProgressBarBase.update(self, value=value) + StdRedirectMixin.update(self, value=value) # type: ignore - # Only flush if something was actually written - self.fd.flush() + # Only flush if something was actually written + self.fd.flush() - def start( # type: ignore[override] - self, - max_value: int | None = None, - init: bool = True, - ): + def start(self, max_value=None, init=True, *args, **kwargs): '''Starts measuring time, and prints the bar at 0%. It returns self so you can use it like this: @@ -839,7 +935,7 @@ def start( # type: ignore[override] max_value (int): The maximum value of the progressbar init (bool): (Re)Initialize the progressbar, this is useful if you wish to reuse the same progressbar but can be disabled if - data needs to be passed along to the next run + data needs to be persisted between runs >>> pbar = ProgressBar().start() >>> for i in range(100): @@ -869,22 +965,48 @@ def start( # type: ignore[override] if not self.widgets: self.widgets = self.default_widgets() - if self.prefix: - self.widgets.insert( - 0, widgets.FormatLabel(self.prefix, new_style=True) - ) - # Unset the prefix variable after applying so an extra start() - # won't keep copying it - self.prefix = None + self._init_prefix() + self._init_suffix() + self._calculate_poll_interval() + self._verify_max_value() + now = datetime.now() + self.start_time = self.initial_start_time or now + self.last_update_time = now + self._last_update_timer = timeit.default_timer() + self.update(self.min_value, force=True) + + return self + + def _init_suffix(self): if self.suffix: self.widgets.append( - widgets.FormatLabel(self.suffix, new_style=True) + widgets.FormatLabel(self.suffix, new_style=True), ) # Unset the suffix variable after applying so an extra start() # won't keep copying it self.suffix = None + def _init_prefix(self): + if self.prefix: + self.widgets.insert( + 0, + widgets.FormatLabel(self.prefix, new_style=True), + ) + # Unset the prefix variable after applying so an extra start() + # won't keep copying it + self.prefix = None + + def _verify_max_value(self): + if ( + self.max_value is not base.UnknownLength + and self.max_value is not None + and self.max_value < 0 # type: ignore + ): + raise ValueError('max_value out of range, got %r' % self.max_value) + + def _calculate_poll_interval(self) -> None: + self.num_intervals = max(100, self.term_width) for widget in self.widgets: interval: int | float | None = utils.deltas_to_seconds( getattr(widget, 'INTERVAL', None), @@ -896,26 +1018,6 @@ def start( # type: ignore[override] interval, ) - self.num_intervals = max(100, self.term_width) - # The `next_update` is kept for compatibility with external libs: - # https://github.com/WoLpH/python-progressbar/issues/207 - self.next_update = 0 - - if ( - self.max_value is not base.UnknownLength - and self.max_value is not None - and self.max_value < 0 # type: ignore - ): - raise ValueError('max_value out of range, got %r' % self.max_value) - - now = datetime.now() - self.start_time = self.initial_start_time or now - self.last_update_time = now - self._last_update_timer = timeit.default_timer() - self.update(self.min_value, force=True) - - return self - def finish(self, end='\n', dirty=False): ''' Puts the ProgressBar bar in the finished state. @@ -929,7 +1031,6 @@ def finish(self, end='\n', dirty=False): dirty (bool): When True the progressbar kept the current state and won't be set to 100 percent ''' - if not dirty: self.end_time = datetime.now() self.update(self.max_value, force=True) @@ -942,12 +1043,13 @@ def finish(self, end='\n', dirty=False): def currval(self): ''' Legacy method to make progressbar-2 compatible with the original - progressbar package + progressbar package. ''' warnings.warn( 'The usage of `currval` is deprecated, please use ' '`value` instead', DeprecationWarning, + stacklevel=1, ) return self.value @@ -984,7 +1086,7 @@ def default_widgets(self): class NullBar(ProgressBar): ''' Progress bar that does absolutely nothing. Useful for single verbosity - flags + flags. ''' def start(self, *args, **kwargs): diff --git a/progressbar/base.py b/progressbar/base.py index 8e007914..f3f2ef57 100644 --- a/progressbar/base.py +++ b/progressbar/base.py @@ -1,12 +1,13 @@ -# -*- mode: python; coding: utf-8 -*- from python_utils import types class FalseMeta(type): - def __bool__(self): # pragma: no cover + @classmethod + def __bool__(cls): # pragma: no cover return False - def __cmp__(self, other): # pragma: no cover + @classmethod + def __cmp__(cls, other): # pragma: no cover return -1 __nonzero__ = __bool__ diff --git a/progressbar/env.py b/progressbar/env.py new file mode 100644 index 00000000..07e6666f --- /dev/null +++ b/progressbar/env.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import enum +import os +import re +import typing + +from . import base + + +@typing.overload +def env_flag(name: str, default: bool) -> bool: + ... + + +@typing.overload +def env_flag(name: str, default: bool | None = None) -> bool | None: + ... + + +def env_flag(name, default=None): + ''' + Accepts environt variables formatted as y/n, yes/no, 1/0, true/false, + on/off, and returns it as a boolean. + + If the environment variable is not defined, or has an unknown value, + returns `default` + ''' + v = os.getenv(name) + if v and v.lower() in ('y', 'yes', 't', 'true', 'on', '1'): + return True + if v and v.lower() in ('n', 'no', 'f', 'false', 'off', '0'): + return False + return default + + +class ColorSupport(enum.IntEnum): + '''Color support for the terminal.''' + + NONE = 0 + XTERM = 16 + XTERM_256 = 256 + XTERM_TRUECOLOR = 16777216 + + @classmethod + def from_env(cls): + '''Get the color support from the environment. + + If any of the environment variables contain `24bit` or `truecolor`, + we will enable true color/24 bit support. If they contain `256`, we + will enable 256 color/8 bit support. If they contain `xterm`, we will + enable 16 color support. Otherwise, we will assume no color support. + + If `JUPYTER_COLUMNS` or `JUPYTER_LINES` is set, we will assume true + color support. + + Note that the highest available value will be used! Having + `COLORTERM=truecolor` will override `TERM=xterm-256color`. + ''' + variables = ( + 'FORCE_COLOR', + 'PROGRESSBAR_ENABLE_COLORS', + 'COLORTERM', + 'TERM', + ) + + if os.environ.get('JUPYTER_COLUMNS') or os.environ.get( + 'JUPYTER_LINES', + ): + # Jupyter notebook always supports true color. + return cls.XTERM_TRUECOLOR + + support = cls.NONE + for variable in variables: + value = os.environ.get(variable) + if value is None: + continue + elif value in {'truecolor', '24bit'}: + # Truecolor support, we don't need to check anything else. + support = cls.XTERM_TRUECOLOR + break + elif '256' in value: + support = max(cls.XTERM_256, support) + elif value == 'xterm': + support = max(cls.XTERM, support) + + return support + + +def is_ansi_terminal( + fd: base.IO, + is_terminal: bool | None = None, +) -> bool: # pragma: no cover + if is_terminal is None: + # Jupyter Notebooks define this variable and support progress bars + if 'JPY_PARENT_PID' in os.environ: + is_terminal = True + # This works for newer versions of pycharm only. With older versions + # there is no way to check. + elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( + 'PYTEST_CURRENT_TEST', + ): + is_terminal = True + + if is_terminal is None: + # check if we are writing to a terminal or not. typically a file object + # is going to return False if the instance has been overridden and + # isatty has not been defined we have no way of knowing so we will not + # use ansi. ansi terminals will typically define one of the 2 + # environment variables. + try: + is_tty = fd.isatty() + # Try and match any of the huge amount of Linux/Unix ANSI consoles + if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')): + is_terminal = True + # ANSICON is a Windows ANSI compatible console + elif 'ANSICON' in os.environ: + is_terminal = True + else: + is_terminal = None + except Exception: + is_terminal = False + + return bool(is_terminal) + + +def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: + if is_terminal is None: + # Full ansi support encompasses what we expect from a terminal + is_terminal = is_ansi_terminal(fd) or None + + if is_terminal is None: + # Allow a environment variable override + is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None) + + if is_terminal is None: # pragma: no cover + # Bare except because a lot can go wrong on different systems. If we do + # get a TTY we know this is a valid terminal + try: + is_terminal = fd.isatty() + except Exception: + is_terminal = False + + return bool(is_terminal) + + +COLOR_SUPPORT = ColorSupport.from_env() +ANSI_TERMS = ( + '([xe]|bv)term', + '(sco)?ansi', + 'cygwin', + 'konsole', + 'linux', + 'rxvt', + 'screen', + 'tmux', + 'vt(10[02]|220|320)', +) +ANSI_TERM_RE = re.compile(f"^({'|'.join(ANSI_TERMS)})", re.IGNORECASE) diff --git a/progressbar/multi.py b/progressbar/multi.py new file mode 100644 index 00000000..be1ca7d9 --- /dev/null +++ b/progressbar/multi.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import enum +import io +import itertools +import operator +import sys +import threading +import time +import timeit +import typing +from datetime import timedelta + +import python_utils + +from . import bar, terminal +from .terminal import stream + +SortKeyFunc = typing.Callable[[bar.ProgressBar], typing.Any] + + +class SortKey(str, enum.Enum): + ''' + Sort keys for the MultiBar. + + This is a string enum, so you can use any + progressbar attribute or property as a sort key. + + Note that the multibar defaults to lazily rendering only the changed + progressbars. This means that sorting by dynamic attributes such as + `value` might result in more rendering which can have a small performance + impact. + ''' + + CREATED = 'index' + LABEL = 'label' + VALUE = 'value' + PERCENTAGE = 'percentage' + + +class MultiBar(typing.Dict[str, bar.ProgressBar]): + fd: typing.TextIO + _buffer: io.StringIO + + #: The format for the label to append/prepend to the progressbar + label_format: str + #: Automatically prepend the label to the progressbars + prepend_label: bool + #: Automatically append the label to the progressbars + append_label: bool + #: If `initial_format` is `None`, the progressbar rendering is used + # which will *start* the progressbar. That means the progressbar will + # have no knowledge of your data and will run as an infinite progressbar. + initial_format: str | None + #: If `finished_format` is `None`, the progressbar rendering is used. + finished_format: str | None + + #: The multibar updates at a fixed interval regardless of the progressbar + # updates + update_interval: float + remove_finished: float | None + + #: The kwargs passed to the progressbar constructor + progressbar_kwargs: dict[str, typing.Any] + + #: The progressbar sorting key function + sort_keyfunc: SortKeyFunc + + _previous_output: list[str] + _finished_at: dict[bar.ProgressBar, float] + _labeled: set[bar.ProgressBar] + _print_lock: threading.RLock = threading.RLock() + _thread: threading.Thread | None = None + _thread_finished: threading.Event = threading.Event() + _thread_closed: threading.Event = threading.Event() + + def __init__( + self, + bars: typing.Iterable[tuple[str, bar.ProgressBar]] | None = None, + fd: typing.TextIO = sys.stderr, + prepend_label: bool = True, + append_label: bool = False, + label_format='{label:20.20} ', + initial_format: str | None = '{label:20.20} Not yet started', + finished_format: str | None = None, + update_interval: float = 1 / 60.0, # 60fps + show_initial: bool = True, + show_finished: bool = True, + remove_finished: timedelta | float = timedelta(seconds=3600), + sort_key: str | SortKey = SortKey.CREATED, + sort_reverse: bool = True, + sort_keyfunc: SortKeyFunc | None = None, + **progressbar_kwargs, + ): + self.fd = fd + + self.prepend_label = prepend_label + self.append_label = append_label + self.label_format = label_format + self.initial_format = initial_format + self.finished_format = finished_format + + self.update_interval = update_interval + + self.show_initial = show_initial + self.show_finished = show_finished + self.remove_finished = python_utils.delta_to_seconds_or_none( + remove_finished, + ) + + self.progressbar_kwargs = progressbar_kwargs + + if sort_keyfunc is None: + sort_keyfunc = operator.attrgetter(sort_key) + + self.sort_keyfunc = sort_keyfunc + self.sort_reverse = sort_reverse + + self._labeled = set() + self._finished_at = {} + self._previous_output = [] + self._buffer = io.StringIO() + + super().__init__(bars or {}) + + def __setitem__(self, key: str, bar: bar.ProgressBar): + '''Add a progressbar to the multibar.''' + if bar.label != key or not key: # pragma: no branch + bar.label = key + bar.fd = stream.LastLineStream(self.fd) + bar.paused = True + # Essentially `bar.print = self.print`, but `mypy` doesn't like that + bar.print = self.print # type: ignore + + # Just in case someone is using a progressbar with a custom + # constructor and forgot to call the super constructor + if bar.index == -1: + bar.index = next(bar._index_counter) + + super().__setitem__(key, bar) + + def __delitem__(self, key): + '''Remove a progressbar from the multibar.''' + super().__delitem__(key) + self._finished_at.pop(key, None) + self._labeled.discard(key) + + def __getitem__(self, key): + '''Get (and create if needed) a progressbar from the multibar.''' + try: + return super().__getitem__(key) + except KeyError: + progress = bar.ProgressBar(**self.progressbar_kwargs) + self[key] = progress + return progress + + def _label_bar(self, bar: bar.ProgressBar): + if bar in self._labeled: # pragma: no branch + return + + assert bar.widgets, 'Cannot prepend label to empty progressbar' + + if self.prepend_label: # pragma: no branch + self._labeled.add(bar) + bar.widgets.insert(0, self.label_format.format(label=bar.label)) + + if self.append_label and bar not in self._labeled: # pragma: no branch + self._labeled.add(bar) + bar.widgets.append(self.label_format.format(label=bar.label)) + + def render(self, flush: bool = True, force: bool = False): + '''Render the multibar to the given stream.''' + now = timeit.default_timer() + expired = now - self.remove_finished if self.remove_finished else None + + # sourcery skip: list-comprehension + output: list[str] = [] + for bar_ in self.get_sorted_bars(): + if not bar_.started() and not self.show_initial: + continue + + output.extend( + iter(self._render_bar(bar_, expired=expired, now=now)), + ) + + with self._print_lock: + # Clear the previous output if progressbars have been removed + for i in range(len(output), len(self._previous_output)): + self._buffer.write( + terminal.clear_line(i + 1), + ) # pragma: no cover + + # Add empty lines to the end of the output if progressbars have + # been added + for _ in range(len(self._previous_output), len(output)): + # Adding a new line so we don't overwrite previous output + self._buffer.write('\n') + + for i, (previous, current) in enumerate( + itertools.zip_longest( + self._previous_output, + output, + fillvalue='', + ), + ): + if previous != current or force: # pragma: no branch + self.print( + '\r' + current.strip(), + offset=i + 1, + end='', + clear=False, + flush=False, + ) + + self._previous_output = output + + if flush: # pragma: no branch + self.flush() + + def _render_bar( + self, + bar_: bar.ProgressBar, + now, + expired, + ) -> typing.Iterable[str]: + def update(force=True, write=True): # pragma: no cover + self._label_bar(bar_) + bar_.update(force=force) + if write: + yield typing.cast(stream.LastLineStream, bar_.fd).line + + if bar_.finished(): + yield from self._render_finished_bar(bar_, now, expired, update) + + elif bar_.started(): + update() + else: + if self.initial_format is None: + bar_.start() + update() + else: + yield self.initial_format.format(label=bar_.label) + + def _render_finished_bar( + self, + bar_: bar.ProgressBar, + now, + expired, + update, + ) -> typing.Iterable[str]: + if bar_ not in self._finished_at: + self._finished_at[bar_] = now + # Force update to get the finished format + update(write=False) + + if ( + self.remove_finished + and expired is not None + and expired >= self._finished_at[bar_] + ): + del self[bar_.label] + return + + if not self.show_finished: + return + + if bar_.finished(): # pragma: no branch + if self.finished_format is None: + update(force=False) + else: # pragma: no cover + yield self.finished_format.format(label=bar_.label) + + def print( + self, + *args, + end='\n', + offset=None, + flush=True, + clear=True, + **kwargs, + ): + ''' + Print to the progressbar stream without overwriting the progressbars. + + Args: + end: The string to append to the end of the output + offset: The number of lines to offset the output by. If None, the + output will be printed above the progressbars + flush: Whether to flush the output to the stream + clear: If True, the line will be cleared before printing. + **kwargs: Additional keyword arguments to pass to print + ''' + with self._print_lock: + if offset is None: + offset = len(self._previous_output) + + if not clear: + self._buffer.write(terminal.PREVIOUS_LINE(offset)) + + if clear: + self._buffer.write(terminal.PREVIOUS_LINE(offset)) + self._buffer.write(terminal.CLEAR_LINE_ALL()) + + print(*args, **kwargs, file=self._buffer, end=end) + + if clear: + self._buffer.write(terminal.CLEAR_SCREEN_TILL_END()) + for line in self._previous_output: + self._buffer.write(line.strip()) + self._buffer.write('\n') + + else: + self._buffer.write(terminal.NEXT_LINE(offset)) + + if flush: + self.flush() + + def flush(self): + self.fd.write(self._buffer.getvalue()) + self._buffer.truncate(0) + self.fd.flush() + + def run(self, join=True): + ''' + Start the multibar render loop and run the progressbars until they + have force _thread_finished. + ''' + while not self._thread_finished.is_set(): # pragma: no branch + self.render() + time.sleep(self.update_interval) + + if join or self._thread_closed.is_set(): + # If the thread is closed, we need to check if the progressbars + # have finished. If they have, we can exit the loop + for bar_ in self.values(): # pragma: no cover + if not bar_.finished(): + break + else: + # Render one last time to make sure the progressbars are + # correctly finished + self.render(force=True) + return + + def start(self): + assert not self._thread, 'Multibar already started' + self._thread_closed.set() + self._thread = threading.Thread(target=self.run, args=(False,)) + self._thread.start() + + def join(self, timeout=None): + if self._thread is not None: + self._thread_closed.set() + self._thread.join(timeout=timeout) + self._thread = None + + def stop(self, timeout: float | None = None): + self._thread_finished.set() + self.join(timeout=timeout) + + def get_sorted_bars(self): + return sorted( + self.values(), + key=self.sort_keyfunc, + reverse=self.sort_reverse, + ) + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.join() diff --git a/progressbar/shortcuts.py b/progressbar/shortcuts.py index dd61c9cb..b16f19af 100644 --- a/progressbar/shortcuts.py +++ b/progressbar/shortcuts.py @@ -19,5 +19,4 @@ def progressbar( **kwargs, ) - for result in progressbar(iterator): - yield result + yield from progressbar(iterator) diff --git a/progressbar/terminal/__init__.py b/progressbar/terminal/__init__.py new file mode 100644 index 00000000..037cce63 --- /dev/null +++ b/progressbar/terminal/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .base import * # noqa F403 +from .stream import * # noqa F403 diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py new file mode 100644 index 00000000..8c9b262a --- /dev/null +++ b/progressbar/terminal/base.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import abc +import collections +import colorsys +import threading +from collections import defaultdict + +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar + +from python_utils import converters, types + +from .. import ( + base as pbase, + env, +) +from .os_specific import getch + +ESC = '\x1B' + + +class CSI: + _code: str + _template = ESC + '[{args}{code}' + + def __init__(self, code, *default_args): + self._code = code + self._default_args = default_args + + def __call__(self, *args): + return self._template.format( + args=';'.join(map(str, args or self._default_args)), + code=self._code, + ) + + def __str__(self): + return self() + + +class CSINoArg(CSI): + def __call__(self): + return super().__call__() + + +#: Cursor Position [row;column] (default = [1,1]) +CUP = CSI('H', 1, 1) + +#: Cursor Up Ps Times (default = 1) (CUU) +UP = CSI('A', 1) + +#: Cursor Down Ps Times (default = 1) (CUD) +DOWN = CSI('B', 1) + +#: Cursor Forward Ps Times (default = 1) (CUF) +RIGHT = CSI('C', 1) + +#: Cursor Backward Ps Times (default = 1) (CUB) +LEFT = CSI('D', 1) + +#: Cursor Next Line Ps Times (default = 1) (CNL) +#: Same as Cursor Down Ps Times +NEXT_LINE = CSI('E', 1) + +#: Cursor Preceding Line Ps Times (default = 1) (CPL) +#: Same as Cursor Up Ps Times +PREVIOUS_LINE = CSI('F', 1) + +#: Cursor Character Absolute [column] (default = [row,1]) (CHA) +COLUMN = CSI('G', 1) + +#: Erase in Display (ED) +CLEAR_SCREEN = CSI('J', 0) + +#: Erase till end of screen +CLEAR_SCREEN_TILL_END = CSINoArg('0J') + +#: Erase till start of screen +CLEAR_SCREEN_TILL_START = CSINoArg('1J') + +#: Erase whole screen +CLEAR_SCREEN_ALL = CSINoArg('2J') + +#: Erase whole screen and history +CLEAR_SCREEN_ALL_AND_HISTORY = CSINoArg('3J') + +#: Erase in Line (EL) +CLEAR_LINE_ALL = CSI('K') + +#: Erase in Line from Cursor to End of Line (default) +CLEAR_LINE_RIGHT = CSINoArg('0K') + +#: Erase in Line from Cursor to Beginning of Line +CLEAR_LINE_LEFT = CSINoArg('1K') + +#: Erase Line containing Cursor +CLEAR_LINE = CSINoArg('2K') + +#: Scroll up Ps lines (default = 1) (SU) +#: Scroll down Ps lines (default = 1) (SD) +SCROLL_UP = CSI('S') +SCROLL_DOWN = CSI('T') + +#: Save Cursor Position (SCP) +SAVE_CURSOR = CSINoArg('s') + +#: Restore Cursor Position (RCP) +RESTORE_CURSOR = CSINoArg('u') + +#: Cursor Visibility (DECTCEM) +HIDE_CURSOR = CSINoArg('?25l') +SHOW_CURSOR = CSINoArg('?25h') + + +# +# UP = CSI + '{n}A' # Cursor Up +# DOWN = CSI + '{n}B' # Cursor Down +# RIGHT = CSI + '{n}C' # Cursor Forward +# LEFT = CSI + '{n}D' # Cursor Backward +# NEXT = CSI + '{n}E' # Cursor Next Line +# PREV = CSI + '{n}F' # Cursor Previous Line +# MOVE_COLUMN = CSI + '{n}G' # Cursor Horizontal Absolute +# MOVE = CSI + '{row};{column}H' # Cursor Position [row;column] (default = [ +# 1,1]) +# +# CLEAR = CSI + '{n}J' # Clear (part of) the screen +# CLEAR_BOTTOM = CLEAR.format(n=0) # Clear from cursor to end of screen +# CLEAR_TOP = CLEAR.format(n=1) # Clear from cursor to beginning of screen +# CLEAR_SCREEN = CLEAR.format(n=2) # Clear Screen +# CLEAR_WIPE = CLEAR.format(n=3) # Clear Screen and scrollback buffer +# +# CLEAR_LINE = CSI + '{n}K' # Erase in Line +# CLEAR_LINE_RIGHT = CLEAR_LINE.format(n=0) # Clear from cursor to end of line +# CLEAR_LINE_LEFT = CLEAR_LINE.format(n=1) # Clear from cursor to beginning +# of line +# CLEAR_LINE_ALL = CLEAR_LINE.format(n=2) # Clear Line + + +def clear_line(n): + return UP(n) + CLEAR_LINE_ALL() + DOWN(n) + + +# Report Cursor Position (CPR), response = [row;column] as row;columnR +class _CPR(str): # pragma: no cover + _response_lock = threading.Lock() + + def __call__(self, stream) -> tuple[int, int]: + res: str = '' + + with self._response_lock: + stream.write(str(self)) + stream.flush() + + while not res.endswith('R'): + char = getch() + + if char is not None: + res += char + + res_list = res[2:-1].split(';') + + res_list = tuple( + int(item) if item.isdigit() else item for item in res_list + ) + + if len(res_list) == 1: + return types.cast(types.Tuple[int, int], res_list[0]) + + return types.cast(types.Tuple[int, int], tuple(res_list)) + + def row(self, stream): + row, _ = self(stream) + return row + + def column(self, stream): + _, column = self(stream) + return column + + +class RGB(collections.namedtuple('RGB', ['red', 'green', 'blue'])): + __slots__ = () + + def __str__(self): + return self.rgb + + @property + def rgb(self): + return f'rgb({self.red}, {self.green}, {self.blue})' + + @property + def hex(self): + return f'#{self.red:02x}{self.green:02x}{self.blue:02x}' + + @property + def to_ansi_16(self): + # Using int instead of round because it maps slightly better + red = int(self.red / 255) + green = int(self.green / 255) + blue = int(self.blue / 255) + return (blue << 2) | (green << 1) | red + + @property + def to_ansi_256(self): + red = round(self.red / 255 * 5) + green = round(self.green / 255 * 5) + blue = round(self.blue / 255 * 5) + return 16 + 36 * red + 6 * green + blue + + def interpolate(self, end: RGB, step: float) -> RGB: + return RGB( + int(self.red + (end.red - self.red) * step), + int(self.green + (end.green - self.green) * step), + int(self.blue + (end.blue - self.blue) * step), + ) + + +class HSL(collections.namedtuple('HSL', ['hue', 'saturation', 'lightness'])): + ''' + Hue, Saturation, Lightness color. + + Hue is a value between 0 and 360, saturation and lightness are between 0(%) + and 100(%). + + ''' + + __slots__ = () + + @classmethod + def from_rgb(cls, rgb: RGB) -> HSL: + ''' + Convert a 0-255 RGB color to a 0-255 HLS color. + ''' + hls = colorsys.rgb_to_hls( + rgb.red / 255, + rgb.green / 255, + rgb.blue / 255, + ) + return cls( + round(hls[0] * 360), + round(hls[2] * 100), + round(hls[1] * 100), + ) + + def interpolate(self, end: HSL, step: float) -> HSL: + return HSL( + self.hue + (end.hue - self.hue) * step, + self.lightness + (end.lightness - self.lightness) * step, + self.saturation + (end.saturation - self.saturation) * step, + ) + + +class ColorBase(abc.ABC): + def get_color(self, value: float) -> Color: + raise NotImplementedError() + + +class Color( + collections.namedtuple( + 'Color', + [ + 'rgb', + 'hls', + 'name', + 'xterm', + ], + ), + ColorBase, +): + ''' + Color base class. + + This class contains the colors in RGB (Red, Green, Blue), HSL (Hue, + Lightness, Saturation) and Xterm (8-bit) formats. It also contains the + color name. + + To make a custom color the only required arguments are the RGB values. + The other values will be automatically interpolated from that if needed, + but you can be more explicitly if you wish. + ''' + + __slots__ = () + + def __call__(self, value: str) -> str: + return self.fg(value) + + @property + def fg(self): + return SGRColor(self, 38, 39) + + @property + def bg(self): + return SGRColor(self, 48, 49) + + @property + def underline(self): + return SGRColor(self, 58, 59) + + @property + def ansi(self) -> types.Optional[str]: + if ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_TRUECOLOR + ): # pragma: no branch + return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' + + if self.xterm: # pragma: no branch + color = self.xterm + elif ( + env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 + ): # pragma: no branch + color = self.rgb.to_ansi_256 + elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch + color = self.rgb.to_ansi_16 + else: # pragma: no branch + return None + + return f'5;{color}' + + def interpolate(self, end: Color, step: float) -> Color: + return Color( + self.rgb.interpolate(end.rgb, step), + self.hls.interpolate(end.hls, step), + self.name if step < 0.5 else end.name, + self.xterm if step < 0.5 else end.xterm, + ) + + def __str__(self): + return self.name + + def __repr__(self): + return f'{self.__class__.__name__}({self.name!r})' + + def __hash__(self): + return hash(self.rgb) + + +class Colors: + by_name: ClassVar[ + defaultdict[str, types.List[Color]] + ] = collections.defaultdict(list) + by_lowername: ClassVar[ + defaultdict[str, types.List[Color]] + ] = collections.defaultdict(list) + by_hex: ClassVar[ + defaultdict[str, types.List[Color]] + ] = collections.defaultdict(list) + by_rgb: ClassVar[ + defaultdict[RGB, types.List[Color]] + ] = collections.defaultdict(list) + by_hls: ClassVar[ + defaultdict[HSL, types.List[Color]] + ] = collections.defaultdict(list) + by_xterm: ClassVar[dict[int, Color]] = dict() + + @classmethod + def register( + cls, + rgb: RGB, + hls: types.Optional[HSL] = None, + name: types.Optional[str] = None, + xterm: types.Optional[int] = None, + ) -> Color: + color = Color(rgb, hls, name, xterm) + + if name: + cls.by_name[name].append(color) + cls.by_lowername[name.lower()].append(color) + + if hls is None: + hls = HSL.from_rgb(rgb) + + cls.by_hex[rgb.hex].append(color) + cls.by_rgb[rgb].append(color) + cls.by_hls[hls].append(color) + + if xterm is not None: + cls.by_xterm[xterm] = color + + return color + + @classmethod + def interpolate(cls, color_a: Color, color_b: Color, step: float) -> Color: + return color_a.interpolate(color_b, step) + + +class ColorGradient(ColorBase): + def __init__(self, *colors: Color, interpolate=Colors.interpolate): + assert colors + self.colors = colors + self.interpolate = interpolate + + def __call__(self, value: float) -> Color: + return self.get_color(value) + + def get_color(self, value: float) -> Color: + 'Map a value from 0 to 1 to a color.' + if ( + value == pbase.Undefined + or value == pbase.UnknownLength + or value <= 0 + ): + return self.colors[0] + elif value >= 1: + return self.colors[-1] + + max_color_idx = len(self.colors) - 1 + if max_color_idx == 0: + return self.colors[0] + elif self.interpolate: + if max_color_idx > 1: + index = round( + converters.remap(value, 0, 1, 0, max_color_idx - 1), + ) + else: + index = 0 + + step = converters.remap( + value, + index / (max_color_idx), + (index + 1) / (max_color_idx), + 0, + 1, + ) + color = self.interpolate( + self.colors[index], + self.colors[index + 1], + float(step), + ) + else: + index = round(converters.remap(value, 0, 1, 0, max_color_idx)) + color = self.colors[index] + + return color + + +OptionalColor = types.Union[Color, ColorGradient, None] + + +def get_color(value: float, color: OptionalColor) -> Color | None: + if isinstance(color, ColorGradient): + color = color(value) + return color + + +def apply_colors( + text: str, + percentage: float | None = None, + *, + fg: OptionalColor = None, + bg: OptionalColor = None, + fg_none: Color | None = None, + bg_none: Color | None = None, + **kwargs: types.Any, +) -> str: + '''Apply colors/gradients to a string depending on the given percentage. + + When percentage is `None`, the `fg_none` and `bg_none` colors will be used. + Otherwise, the `fg` and `bg` colors will be used. If the colors are + gradients, the color will be interpolated depending on the percentage. + ''' + if percentage is None: + if fg_none is not None: + text = fg_none.fg(text) + if bg_none is not None: + text = bg_none.bg(text) + elif fg is not None or bg is not None: + fg = get_color(percentage * 0.01, fg) + bg = get_color(percentage * 0.01, bg) + + if fg is not None: # pragma: no branch + text = fg.fg(text) + if bg is not None: # pragma: no branch + text = bg.bg(text) + + return text + + +class SGR(CSI): + _start_code: int + _end_code: int + _code = 'm' + __slots__ = '_start_code', '_end_code' + + def __init__(self, start_code: int, end_code: int): + self._start_code = start_code + self._end_code = end_code + + @property + def _start_template(self): + return super().__call__(self._start_code) + + @property + def _end_template(self): + return super().__call__(self._end_code) + + def __call__(self, text, *args): + return self._start_template + text + self._end_template + + +class SGRColor(SGR): + __slots__ = '_color', '_start_code', '_end_code' + + def __init__(self, color: Color, start_code: int, end_code: int): + self._color = color + super().__init__(start_code, end_code) + + @property + def _start_template(self): + return CSI.__call__(self, self._start_code, self._color.ansi) + + +encircled = SGR(52, 54) +framed = SGR(51, 54) +overline = SGR(53, 55) +bold = SGR(1, 22) +gothic = SGR(20, 10) +italic = SGR(3, 23) +strike_through = SGR(9, 29) +fast_blink = SGR(6, 25) +slow_blink = SGR(5, 25) +underline = SGR(4, 24) +double_underline = SGR(21, 24) +faint = SGR(2, 22) +inverse = SGR(7, 27) diff --git a/progressbar/terminal/colors.py b/progressbar/terminal/colors.py new file mode 100644 index 00000000..53354acc --- /dev/null +++ b/progressbar/terminal/colors.py @@ -0,0 +1,1070 @@ +# Based on: https://www.ditig.com/256-colors-cheat-sheet +import os + +from progressbar.terminal.base import HSL, RGB, ColorGradient, Colors + +black = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Black', 0) +maroon = Colors.register(RGB(128, 0, 0), HSL(0, 100, 25), 'Maroon', 1) +green = Colors.register(RGB(0, 128, 0), HSL(120, 100, 25), 'Green', 2) +olive = Colors.register(RGB(128, 128, 0), HSL(60, 100, 25), 'Olive', 3) +navy = Colors.register(RGB(0, 0, 128), HSL(240, 100, 25), 'Navy', 4) +purple = Colors.register(RGB(128, 0, 128), HSL(300, 100, 25), 'Purple', 5) +teal = Colors.register(RGB(0, 128, 128), HSL(180, 100, 25), 'Teal', 6) +silver = Colors.register(RGB(192, 192, 192), HSL(0, 0, 75), 'Silver', 7) +grey = Colors.register(RGB(128, 128, 128), HSL(0, 0, 50), 'Grey', 8) +red = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red', 9) +lime = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Lime', 10) +yellow = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow', 11) +blue = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue', 12) +fuchsia = Colors.register(RGB(255, 0, 255), HSL(300, 100, 50), 'Fuchsia', 13) +aqua = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Aqua', 14) +white = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'White', 15) +grey0 = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Grey0', 16) +navy_blue = Colors.register(RGB(0, 0, 95), HSL(240, 100, 18), 'NavyBlue', 17) +dark_blue = Colors.register(RGB(0, 0, 135), HSL(240, 100, 26), 'DarkBlue', 18) +blue3 = Colors.register(RGB(0, 0, 175), HSL(240, 100, 34), 'Blue3', 19) +blue3 = Colors.register(RGB(0, 0, 215), HSL(240, 100, 42), 'Blue3', 20) +blue1 = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue1', 21) +dark_green = Colors.register(RGB(0, 95, 0), HSL(120, 100, 18), 'DarkGreen', 22) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 95), + HSL(180, 100, 18), + 'DeepSkyBlue4', + 23, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 135), + HSL(97, 100, 26), + 'DeepSkyBlue4', + 24, +) +deep_sky_blue4 = Colors.register( + RGB(0, 95, 175), + HSL(7, 100, 34), + 'DeepSkyBlue4', + 25, +) +dodger_blue3 = Colors.register( + RGB(0, 95, 215), + HSL(13, 100, 42), + 'DodgerBlue3', + 26, +) +dodger_blue2 = Colors.register( + RGB(0, 95, 255), + HSL(17, 100, 50), + 'DodgerBlue2', + 27, +) +green4 = Colors.register(RGB(0, 135, 0), HSL(120, 100, 26), 'Green4', 28) +spring_green4 = Colors.register( + RGB(0, 135, 95), + HSL(62, 100, 26), + 'SpringGreen4', + 29, +) +turquoise4 = Colors.register( + RGB(0, 135, 135), + HSL(180, 100, 26), + 'Turquoise4', + 30, +) +deep_sky_blue3 = Colors.register( + RGB(0, 135, 175), + HSL(93, 100, 34), + 'DeepSkyBlue3', + 31, +) +deep_sky_blue3 = Colors.register( + RGB(0, 135, 215), + HSL(2, 100, 42), + 'DeepSkyBlue3', + 32, +) +dodger_blue1 = Colors.register( + RGB(0, 135, 255), + HSL(8, 100, 50), + 'DodgerBlue1', + 33, +) +green3 = Colors.register(RGB(0, 175, 0), HSL(120, 100, 34), 'Green3', 34) +spring_green3 = Colors.register( + RGB(0, 175, 95), + HSL(52, 100, 34), + 'SpringGreen3', + 35, +) +dark_cyan = Colors.register(RGB(0, 175, 135), HSL(66, 100, 34), 'DarkCyan', 36) +light_sea_green = Colors.register( + RGB(0, 175, 175), + HSL(180, 100, 34), + 'LightSeaGreen', + 37, +) +deep_sky_blue2 = Colors.register( + RGB(0, 175, 215), + HSL(91, 100, 42), + 'DeepSkyBlue2', + 38, +) +deep_sky_blue1 = Colors.register( + RGB(0, 175, 255), + HSL(98, 100, 50), + 'DeepSkyBlue1', + 39, +) +green3 = Colors.register(RGB(0, 215, 0), HSL(120, 100, 42), 'Green3', 40) +spring_green3 = Colors.register( + RGB(0, 215, 95), + HSL(46, 100, 42), + 'SpringGreen3', + 41, +) +spring_green2 = Colors.register( + RGB(0, 215, 135), + HSL(57, 100, 42), + 'SpringGreen2', + 42, +) +cyan3 = Colors.register(RGB(0, 215, 175), HSL(68, 100, 42), 'Cyan3', 43) +dark_turquoise = Colors.register( + RGB(0, 215, 215), + HSL(180, 100, 42), + 'DarkTurquoise', + 44, +) +turquoise2 = Colors.register( + RGB(0, 215, 255), + HSL(89, 100, 50), + 'Turquoise2', + 45, +) +green1 = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Green1', 46) +spring_green2 = Colors.register( + RGB(0, 255, 95), + HSL(42, 100, 50), + 'SpringGreen2', + 47, +) +spring_green1 = Colors.register( + RGB(0, 255, 135), + HSL(51, 100, 50), + 'SpringGreen1', + 48, +) +medium_spring_green = Colors.register( + RGB(0, 255, 175), + HSL(61, 100, 50), + 'MediumSpringGreen', + 49, +) +cyan2 = Colors.register(RGB(0, 255, 215), HSL(70, 100, 50), 'Cyan2', 50) +cyan1 = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Cyan1', 51) +dark_red = Colors.register(RGB(95, 0, 0), HSL(0, 100, 18), 'DarkRed', 52) +deep_pink4 = Colors.register( + RGB(95, 0, 95), + HSL(300, 100, 18), + 'DeepPink4', + 53, +) +purple4 = Colors.register(RGB(95, 0, 135), HSL(82, 100, 26), 'Purple4', 54) +purple4 = Colors.register(RGB(95, 0, 175), HSL(72, 100, 34), 'Purple4', 55) +purple3 = Colors.register(RGB(95, 0, 215), HSL(66, 100, 42), 'Purple3', 56) +blue_violet = Colors.register( + RGB(95, 0, 255), + HSL(62, 100, 50), + 'BlueViolet', + 57, +) +orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 18), 'Orange4', 58) +grey37 = Colors.register(RGB(95, 95, 95), HSL(0, 0, 37), 'Grey37', 59) +medium_purple4 = Colors.register( + RGB(95, 95, 135), + HSL(240, 17, 45), + 'MediumPurple4', + 60, +) +slate_blue3 = Colors.register( + RGB(95, 95, 175), + HSL(240, 33, 52), + 'SlateBlue3', + 61, +) +slate_blue3 = Colors.register( + RGB(95, 95, 215), + HSL(240, 60, 60), + 'SlateBlue3', + 62, +) +royal_blue1 = Colors.register( + RGB(95, 95, 255), + HSL(240, 100, 68), + 'RoyalBlue1', + 63, +) +chartreuse4 = Colors.register( + RGB(95, 135, 0), + HSL(7, 100, 26), + 'Chartreuse4', + 64, +) +dark_sea_green4 = Colors.register( + RGB(95, 135, 95), + HSL(120, 17, 45), + 'DarkSeaGreen4', + 65, +) +pale_turquoise4 = Colors.register( + RGB(95, 135, 135), + HSL(180, 17, 45), + 'PaleTurquoise4', + 66, +) +steel_blue = Colors.register( + RGB(95, 135, 175), + HSL(210, 33, 52), + 'SteelBlue', + 67, +) +steel_blue3 = Colors.register( + RGB(95, 135, 215), + HSL(220, 60, 60), + 'SteelBlue3', + 68, +) +cornflower_blue = Colors.register( + RGB(95, 135, 255), + HSL(225, 100, 68), + 'CornflowerBlue', + 69, +) +chartreuse3 = Colors.register( + RGB(95, 175, 0), + HSL(7, 100, 34), + 'Chartreuse3', + 70, +) +dark_sea_green4 = Colors.register( + RGB(95, 175, 95), + HSL(120, 33, 52), + 'DarkSeaGreen4', + 71, +) +cadet_blue = Colors.register( + RGB(95, 175, 135), + HSL(150, 33, 52), + 'CadetBlue', + 72, +) +cadet_blue = Colors.register( + RGB(95, 175, 175), + HSL(180, 33, 52), + 'CadetBlue', + 73, +) +sky_blue3 = Colors.register( + RGB(95, 175, 215), + HSL(200, 60, 60), + 'SkyBlue3', + 74, +) +steel_blue1 = Colors.register( + RGB(95, 175, 255), + HSL(210, 100, 68), + 'SteelBlue1', + 75, +) +chartreuse3 = Colors.register( + RGB(95, 215, 0), + HSL(3, 100, 42), + 'Chartreuse3', + 76, +) +pale_green3 = Colors.register( + RGB(95, 215, 95), + HSL(120, 60, 60), + 'PaleGreen3', + 77, +) +sea_green3 = Colors.register( + RGB(95, 215, 135), + HSL(140, 60, 60), + 'SeaGreen3', + 78, +) +aquamarine3 = Colors.register( + RGB(95, 215, 175), + HSL(160, 60, 60), + 'Aquamarine3', + 79, +) +medium_turquoise = Colors.register( + RGB(95, 215, 215), + HSL(180, 60, 60), + 'MediumTurquoise', + 80, +) +steel_blue1 = Colors.register( + RGB(95, 215, 255), + HSL(195, 100, 68), + 'SteelBlue1', + 81, +) +chartreuse2 = Colors.register( + RGB(95, 255, 0), + HSL(7, 100, 50), + 'Chartreuse2', + 82, +) +sea_green2 = Colors.register( + RGB(95, 255, 95), + HSL(120, 100, 68), + 'SeaGreen2', + 83, +) +sea_green1 = Colors.register( + RGB(95, 255, 135), + HSL(135, 100, 68), + 'SeaGreen1', + 84, +) +sea_green1 = Colors.register( + RGB(95, 255, 175), + HSL(150, 100, 68), + 'SeaGreen1', + 85, +) +aquamarine1 = Colors.register( + RGB(95, 255, 215), + HSL(165, 100, 68), + 'Aquamarine1', + 86, +) +dark_slate_gray2 = Colors.register( + RGB(95, 255, 255), + HSL(180, 100, 68), + 'DarkSlateGray2', + 87, +) +dark_red = Colors.register(RGB(135, 0, 0), HSL(0, 100, 26), 'DarkRed', 88) +deep_pink4 = Colors.register( + RGB(135, 0, 95), + HSL(17, 100, 26), + 'DeepPink4', + 89, +) +dark_magenta = Colors.register( + RGB(135, 0, 135), + HSL(300, 100, 26), + 'DarkMagenta', + 90, +) +dark_magenta = Colors.register( + RGB(135, 0, 175), + HSL(86, 100, 34), + 'DarkMagenta', + 91, +) +dark_violet = Colors.register( + RGB(135, 0, 215), + HSL(77, 100, 42), + 'DarkViolet', + 92, +) +purple = Colors.register(RGB(135, 0, 255), HSL(71, 100, 50), 'Purple', 93) +orange4 = Colors.register(RGB(135, 95, 0), HSL(2, 100, 26), 'Orange4', 94) +light_pink4 = Colors.register( + RGB(135, 95, 95), + HSL(0, 17, 45), + 'LightPink4', + 95, +) +plum4 = Colors.register(RGB(135, 95, 135), HSL(300, 17, 45), 'Plum4', 96) +medium_purple3 = Colors.register( + RGB(135, 95, 175), + HSL(270, 33, 52), + 'MediumPurple3', + 97, +) +medium_purple3 = Colors.register( + RGB(135, 95, 215), + HSL(260, 60, 60), + 'MediumPurple3', + 98, +) +slate_blue1 = Colors.register( + RGB(135, 95, 255), + HSL(255, 100, 68), + 'SlateBlue1', + 99, +) +yellow4 = Colors.register(RGB(135, 135, 0), HSL(60, 100, 26), 'Yellow4', 100) +wheat4 = Colors.register(RGB(135, 135, 95), HSL(60, 17, 45), 'Wheat4', 101) +grey53 = Colors.register(RGB(135, 135, 135), HSL(0, 0, 52), 'Grey53', 102) +light_slate_grey = Colors.register( + RGB(135, 135, 175), + HSL(240, 20, 60), + 'LightSlateGrey', + 103, +) +medium_purple = Colors.register( + RGB(135, 135, 215), + HSL(240, 50, 68), + 'MediumPurple', + 104, +) +light_slate_blue = Colors.register( + RGB(135, 135, 255), + HSL(240, 100, 76), + 'LightSlateBlue', + 105, +) +yellow4 = Colors.register(RGB(135, 175, 0), HSL(3, 100, 34), 'Yellow4', 106) +dark_olive_green3 = Colors.register( + RGB(135, 175, 95), + HSL(90, 33, 52), + 'DarkOliveGreen3', + 107, +) +dark_sea_green = Colors.register( + RGB(135, 175, 135), + HSL(120, 20, 60), + 'DarkSeaGreen', + 108, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 175), + HSL(180, 20, 60), + 'LightSkyBlue3', + 109, +) +light_sky_blue3 = Colors.register( + RGB(135, 175, 215), + HSL(210, 50, 68), + 'LightSkyBlue3', + 110, +) +sky_blue2 = Colors.register( + RGB(135, 175, 255), + HSL(220, 100, 76), + 'SkyBlue2', + 111, +) +chartreuse2 = Colors.register( + RGB(135, 215, 0), + HSL(2, 100, 42), + 'Chartreuse2', + 112, +) +dark_olive_green3 = Colors.register( + RGB(135, 215, 95), + HSL(100, 60, 60), + 'DarkOliveGreen3', + 113, +) +pale_green3 = Colors.register( + RGB(135, 215, 135), + HSL(120, 50, 68), + 'PaleGreen3', + 114, +) +dark_sea_green3 = Colors.register( + RGB(135, 215, 175), + HSL(150, 50, 68), + 'DarkSeaGreen3', + 115, +) +dark_slate_gray3 = Colors.register( + RGB(135, 215, 215), + HSL(180, 50, 68), + 'DarkSlateGray3', + 116, +) +sky_blue1 = Colors.register( + RGB(135, 215, 255), + HSL(200, 100, 76), + 'SkyBlue1', + 117, +) +chartreuse1 = Colors.register( + RGB(135, 255, 0), + HSL(8, 100, 50), + 'Chartreuse1', + 118, +) +light_green = Colors.register( + RGB(135, 255, 95), + HSL(105, 100, 68), + 'LightGreen', + 119, +) +light_green = Colors.register( + RGB(135, 255, 135), + HSL(120, 100, 76), + 'LightGreen', + 120, +) +pale_green1 = Colors.register( + RGB(135, 255, 175), + HSL(140, 100, 76), + 'PaleGreen1', + 121, +) +aquamarine1 = Colors.register( + RGB(135, 255, 215), + HSL(160, 100, 76), + 'Aquamarine1', + 122, +) +dark_slate_gray1 = Colors.register( + RGB(135, 255, 255), + HSL(180, 100, 76), + 'DarkSlateGray1', + 123, +) +red3 = Colors.register(RGB(175, 0, 0), HSL(0, 100, 34), 'Red3', 124) +deep_pink4 = Colors.register( + RGB(175, 0, 95), + HSL(27, 100, 34), + 'DeepPink4', + 125, +) +medium_violet_red = Colors.register( + RGB(175, 0, 135), + HSL(13, 100, 34), + 'MediumVioletRed', + 126, +) +magenta3 = Colors.register( + RGB(175, 0, 175), + HSL(300, 100, 34), + 'Magenta3', + 127, +) +dark_violet = Colors.register( + RGB(175, 0, 215), + HSL(88, 100, 42), + 'DarkViolet', + 128, +) +purple = Colors.register(RGB(175, 0, 255), HSL(81, 100, 50), 'Purple', 129) +dark_orange3 = Colors.register( + RGB(175, 95, 0), + HSL(2, 100, 34), + 'DarkOrange3', + 130, +) +indian_red = Colors.register( + RGB(175, 95, 95), + HSL(0, 33, 52), + 'IndianRed', + 131, +) +hot_pink3 = Colors.register( + RGB(175, 95, 135), + HSL(330, 33, 52), + 'HotPink3', + 132, +) +medium_orchid3 = Colors.register( + RGB(175, 95, 175), + HSL(300, 33, 52), + 'MediumOrchid3', + 133, +) +medium_orchid = Colors.register( + RGB(175, 95, 215), + HSL(280, 60, 60), + 'MediumOrchid', + 134, +) +medium_purple2 = Colors.register( + RGB(175, 95, 255), + HSL(270, 100, 68), + 'MediumPurple2', + 135, +) +dark_goldenrod = Colors.register( + RGB(175, 135, 0), + HSL(6, 100, 34), + 'DarkGoldenrod', + 136, +) +light_salmon3 = Colors.register( + RGB(175, 135, 95), + HSL(30, 33, 52), + 'LightSalmon3', + 137, +) +rosy_brown = Colors.register( + RGB(175, 135, 135), + HSL(0, 20, 60), + 'RosyBrown', + 138, +) +grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 60), 'Grey63', 139) +medium_purple2 = Colors.register( + RGB(175, 135, 215), + HSL(270, 50, 68), + 'MediumPurple2', + 140, +) +medium_purple1 = Colors.register( + RGB(175, 135, 255), + HSL(260, 100, 76), + 'MediumPurple1', + 141, +) +gold3 = Colors.register(RGB(175, 175, 0), HSL(60, 100, 34), 'Gold3', 142) +dark_khaki = Colors.register( + RGB(175, 175, 95), + HSL(60, 33, 52), + 'DarkKhaki', + 143, +) +navajo_white3 = Colors.register( + RGB(175, 175, 135), + HSL(60, 20, 60), + 'NavajoWhite3', + 144, +) +grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 68), 'Grey69', 145) +light_steel_blue3 = Colors.register( + RGB(175, 175, 215), + HSL(240, 33, 76), + 'LightSteelBlue3', + 146, +) +light_steel_blue = Colors.register( + RGB(175, 175, 255), + HSL(240, 100, 84), + 'LightSteelBlue', + 147, +) +yellow3 = Colors.register(RGB(175, 215, 0), HSL(1, 100, 42), 'Yellow3', 148) +dark_olive_green3 = Colors.register( + RGB(175, 215, 95), + HSL(80, 60, 60), + 'DarkOliveGreen3', + 149, +) +dark_sea_green3 = Colors.register( + RGB(175, 215, 135), + HSL(90, 50, 68), + 'DarkSeaGreen3', + 150, +) +dark_sea_green2 = Colors.register( + RGB(175, 215, 175), + HSL(120, 33, 76), + 'DarkSeaGreen2', + 151, +) +light_cyan3 = Colors.register( + RGB(175, 215, 215), + HSL(180, 33, 76), + 'LightCyan3', + 152, +) +light_sky_blue1 = Colors.register( + RGB(175, 215, 255), + HSL(210, 100, 84), + 'LightSkyBlue1', + 153, +) +green_yellow = Colors.register( + RGB(175, 255, 0), + HSL(8, 100, 50), + 'GreenYellow', + 154, +) +dark_olive_green2 = Colors.register( + RGB(175, 255, 95), + HSL(90, 100, 68), + 'DarkOliveGreen2', + 155, +) +pale_green1 = Colors.register( + RGB(175, 255, 135), + HSL(100, 100, 76), + 'PaleGreen1', + 156, +) +dark_sea_green2 = Colors.register( + RGB(175, 255, 175), + HSL(120, 100, 84), + 'DarkSeaGreen2', + 157, +) +dark_sea_green1 = Colors.register( + RGB(175, 255, 215), + HSL(150, 100, 84), + 'DarkSeaGreen1', + 158, +) +pale_turquoise1 = Colors.register( + RGB(175, 255, 255), + HSL(180, 100, 84), + 'PaleTurquoise1', + 159, +) +red3 = Colors.register(RGB(215, 0, 0), HSL(0, 100, 42), 'Red3', 160) +deep_pink3 = Colors.register( + RGB(215, 0, 95), + HSL(33, 100, 42), + 'DeepPink3', + 161, +) +deep_pink3 = Colors.register( + RGB(215, 0, 135), + HSL(22, 100, 42), + 'DeepPink3', + 162, +) +magenta3 = Colors.register(RGB(215, 0, 175), HSL(11, 100, 42), 'Magenta3', 163) +magenta3 = Colors.register( + RGB(215, 0, 215), + HSL(300, 100, 42), + 'Magenta3', + 164, +) +magenta2 = Colors.register(RGB(215, 0, 255), HSL(90, 100, 50), 'Magenta2', 165) +dark_orange3 = Colors.register( + RGB(215, 95, 0), + HSL(6, 100, 42), + 'DarkOrange3', + 166, +) +indian_red = Colors.register( + RGB(215, 95, 95), + HSL(0, 60, 60), + 'IndianRed', + 167, +) +hot_pink3 = Colors.register( + RGB(215, 95, 135), + HSL(340, 60, 60), + 'HotPink3', + 168, +) +hot_pink2 = Colors.register( + RGB(215, 95, 175), + HSL(320, 60, 60), + 'HotPink2', + 169, +) +orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 60), 'Orchid', 170) +medium_orchid1 = Colors.register( + RGB(215, 95, 255), + HSL(285, 100, 68), + 'MediumOrchid1', + 171, +) +orange3 = Colors.register(RGB(215, 135, 0), HSL(7, 100, 42), 'Orange3', 172) +light_salmon3 = Colors.register( + RGB(215, 135, 95), + HSL(20, 60, 60), + 'LightSalmon3', + 173, +) +light_pink3 = Colors.register( + RGB(215, 135, 135), + HSL(0, 50, 68), + 'LightPink3', + 174, +) +pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 68), 'Pink3', 175) +plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 68), 'Plum3', 176) +violet = Colors.register(RGB(215, 135, 255), HSL(280, 100, 76), 'Violet', 177) +gold3 = Colors.register(RGB(215, 175, 0), HSL(8, 100, 42), 'Gold3', 178) +light_goldenrod3 = Colors.register( + RGB(215, 175, 95), + HSL(40, 60, 60), + 'LightGoldenrod3', + 179, +) +tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 68), 'Tan', 180) +misty_rose3 = Colors.register( + RGB(215, 175, 175), + HSL(0, 33, 76), + 'MistyRose3', + 181, +) +thistle3 = Colors.register( + RGB(215, 175, 215), + HSL(300, 33, 76), + 'Thistle3', + 182, +) +plum2 = Colors.register(RGB(215, 175, 255), HSL(270, 100, 84), 'Plum2', 183) +yellow3 = Colors.register(RGB(215, 215, 0), HSL(60, 100, 42), 'Yellow3', 184) +khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 60), 'Khaki3', 185) +light_goldenrod2 = Colors.register( + RGB(215, 215, 135), + HSL(60, 50, 68), + 'LightGoldenrod2', + 186, +) +light_yellow3 = Colors.register( + RGB(215, 215, 175), + HSL(60, 33, 76), + 'LightYellow3', + 187, +) +grey84 = Colors.register(RGB(215, 215, 215), HSL(0, 0, 84), 'Grey84', 188) +light_steel_blue1 = Colors.register( + RGB(215, 215, 255), + HSL(240, 100, 92), + 'LightSteelBlue1', + 189, +) +yellow2 = Colors.register(RGB(215, 255, 0), HSL(9, 100, 50), 'Yellow2', 190) +dark_olive_green1 = Colors.register( + RGB(215, 255, 95), + HSL(75, 100, 68), + 'DarkOliveGreen1', + 191, +) +dark_olive_green1 = Colors.register( + RGB(215, 255, 135), + HSL(80, 100, 76), + 'DarkOliveGreen1', + 192, +) +dark_sea_green1 = Colors.register( + RGB(215, 255, 175), + HSL(90, 100, 84), + 'DarkSeaGreen1', + 193, +) +honeydew2 = Colors.register( + RGB(215, 255, 215), + HSL(120, 100, 92), + 'Honeydew2', + 194, +) +light_cyan1 = Colors.register( + RGB(215, 255, 255), + HSL(180, 100, 92), + 'LightCyan1', + 195, +) +red1 = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red1', 196) +deep_pink2 = Colors.register( + RGB(255, 0, 95), + HSL(37, 100, 50), + 'DeepPink2', + 197, +) +deep_pink1 = Colors.register( + RGB(255, 0, 135), + HSL(28, 100, 50), + 'DeepPink1', + 198, +) +deep_pink1 = Colors.register( + RGB(255, 0, 175), + HSL(18, 100, 50), + 'DeepPink1', + 199, +) +magenta2 = Colors.register(RGB(255, 0, 215), HSL(9, 100, 50), 'Magenta2', 200) +magenta1 = Colors.register( + RGB(255, 0, 255), + HSL(300, 100, 50), + 'Magenta1', + 201, +) +orange_red1 = Colors.register( + RGB(255, 95, 0), + HSL(2, 100, 50), + 'OrangeRed1', + 202, +) +indian_red1 = Colors.register( + RGB(255, 95, 95), + HSL(0, 100, 68), + 'IndianRed1', + 203, +) +indian_red1 = Colors.register( + RGB(255, 95, 135), + HSL(345, 100, 68), + 'IndianRed1', + 204, +) +hot_pink = Colors.register( + RGB(255, 95, 175), + HSL(330, 100, 68), + 'HotPink', + 205, +) +hot_pink = Colors.register( + RGB(255, 95, 215), + HSL(315, 100, 68), + 'HotPink', + 206, +) +medium_orchid1 = Colors.register( + RGB(255, 95, 255), + HSL(300, 100, 68), + 'MediumOrchid1', + 207, +) +dark_orange = Colors.register( + RGB(255, 135, 0), + HSL(1, 100, 50), + 'DarkOrange', + 208, +) +salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 68), 'Salmon1', 209) +light_coral = Colors.register( + RGB(255, 135, 135), + HSL(0, 100, 76), + 'LightCoral', + 210, +) +pale_violet_red1 = Colors.register( + RGB(255, 135, 175), + HSL(340, 100, 76), + 'PaleVioletRed1', + 211, +) +orchid2 = Colors.register( + RGB(255, 135, 215), + HSL(320, 100, 76), + 'Orchid2', + 212, +) +orchid1 = Colors.register( + RGB(255, 135, 255), + HSL(300, 100, 76), + 'Orchid1', + 213, +) +orange1 = Colors.register(RGB(255, 175, 0), HSL(1, 100, 50), 'Orange1', 214) +sandy_brown = Colors.register( + RGB(255, 175, 95), + HSL(30, 100, 68), + 'SandyBrown', + 215, +) +light_salmon1 = Colors.register( + RGB(255, 175, 135), + HSL(20, 100, 76), + 'LightSalmon1', + 216, +) +light_pink1 = Colors.register( + RGB(255, 175, 175), + HSL(0, 100, 84), + 'LightPink1', + 217, +) +pink1 = Colors.register(RGB(255, 175, 215), HSL(330, 100, 84), 'Pink1', 218) +plum1 = Colors.register(RGB(255, 175, 255), HSL(300, 100, 84), 'Plum1', 219) +gold1 = Colors.register(RGB(255, 215, 0), HSL(0, 100, 50), 'Gold1', 220) +light_goldenrod2 = Colors.register( + RGB(255, 215, 95), + HSL(45, 100, 68), + 'LightGoldenrod2', + 221, +) +light_goldenrod2 = Colors.register( + RGB(255, 215, 135), + HSL(40, 100, 76), + 'LightGoldenrod2', + 222, +) +navajo_white1 = Colors.register( + RGB(255, 215, 175), + HSL(30, 100, 84), + 'NavajoWhite1', + 223, +) +misty_rose1 = Colors.register( + RGB(255, 215, 215), + HSL(0, 100, 92), + 'MistyRose1', + 224, +) +thistle1 = Colors.register( + RGB(255, 215, 255), + HSL(300, 100, 92), + 'Thistle1', + 225, +) +yellow1 = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow1', 226) +light_goldenrod1 = Colors.register( + RGB(255, 255, 95), + HSL(60, 100, 68), + 'LightGoldenrod1', + 227, +) +khaki1 = Colors.register(RGB(255, 255, 135), HSL(60, 100, 76), 'Khaki1', 228) +wheat1 = Colors.register(RGB(255, 255, 175), HSL(60, 100, 84), 'Wheat1', 229) +cornsilk1 = Colors.register( + RGB(255, 255, 215), + HSL(60, 100, 92), + 'Cornsilk1', + 230, +) +grey100 = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'Grey100', 231) +grey3 = Colors.register(RGB(8, 8, 8), HSL(0, 0, 3), 'Grey3', 232) +grey7 = Colors.register(RGB(18, 18, 18), HSL(0, 0, 7), 'Grey7', 233) +grey11 = Colors.register(RGB(28, 28, 28), HSL(0, 0, 10), 'Grey11', 234) +grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 14), 'Grey15', 235) +grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 18), 'Grey19', 236) +grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 22), 'Grey23', 237) +grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 26), 'Grey27', 238) +grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 30), 'Grey30', 239) +grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 34), 'Grey35', 240) +grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 37), 'Grey39', 241) +grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 40), 'Grey42', 242) +grey46 = Colors.register(RGB(118, 118, 118), HSL(0, 0, 46), 'Grey46', 243) +grey50 = Colors.register(RGB(128, 128, 128), HSL(0, 0, 50), 'Grey50', 244) +grey54 = Colors.register(RGB(138, 138, 138), HSL(0, 0, 54), 'Grey54', 245) +grey58 = Colors.register(RGB(148, 148, 148), HSL(0, 0, 58), 'Grey58', 246) +grey62 = Colors.register(RGB(158, 158, 158), HSL(0, 0, 61), 'Grey62', 247) +grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 65), 'Grey66', 248) +grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 69), 'Grey70', 249) +grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 73), 'Grey74', 250) +grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 77), 'Grey78', 251) +grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 81), 'Grey82', 252) +grey85 = Colors.register(RGB(218, 218, 218), HSL(0, 0, 85), 'Grey85', 253) +grey89 = Colors.register(RGB(228, 228, 228), HSL(0, 0, 89), 'Grey89', 254) +grey93 = Colors.register(RGB(238, 238, 238), HSL(0, 0, 93), 'Grey93', 255) + +dark_gradient = ColorGradient( + red1, + orange_red1, + dark_orange, + orange1, + yellow1, + yellow2, + green_yellow, + green1, +) +light_gradient = ColorGradient( + red1, + orange_red1, + dark_orange, + orange1, + gold3, + dark_olive_green3, + yellow4, + green3, +) +bg_gradient = ColorGradient(black) + +# Check if the background is light or dark. This is by no means a foolproof +# method, but there is no reliable way to detect this. +_colorfgbg = os.environ.get('COLORFGBG', '15;0').split(';') +if _colorfgbg[-1] == str(white.xterm): # pragma: no cover + # Light background + gradient = light_gradient + primary = black +else: + # Default, expect a dark background + gradient = dark_gradient + primary = white diff --git a/progressbar/terminal/os_specific/__init__.py b/progressbar/terminal/os_specific/__init__.py new file mode 100644 index 00000000..3d27cf5c --- /dev/null +++ b/progressbar/terminal/os_specific/__init__.py @@ -0,0 +1,22 @@ +import sys + +if sys.platform.startswith('win'): + from .windows import ( + getch as _getch, + reset_console_mode as _reset_console_mode, + set_console_mode as _set_console_mode, + ) + +else: + from .posix import getch as _getch + + def _reset_console_mode(): + pass + + def _set_console_mode(): + pass + + +getch = _getch +reset_console_mode = _reset_console_mode +set_console_mode = _set_console_mode diff --git a/progressbar/terminal/os_specific/posix.py b/progressbar/terminal/os_specific/posix.py new file mode 100644 index 00000000..e9bd475e --- /dev/null +++ b/progressbar/terminal/os_specific/posix.py @@ -0,0 +1,15 @@ +import sys +import termios +import tty + + +def getch(): + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(sys.stdin.fileno()) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + return ch diff --git a/progressbar/terminal/os_specific/windows.py b/progressbar/terminal/os_specific/windows.py new file mode 100644 index 00000000..fd19ad51 --- /dev/null +++ b/progressbar/terminal/os_specific/windows.py @@ -0,0 +1,137 @@ +# ruff: noqa: N801 +''' +Windows specific code for the terminal. + +Note that the naming convention here is non-pythonic because we are +matching the Windows API naming. +''' +import ctypes +from ctypes.wintypes import ( + BOOL as _BOOL, + CHAR as _CHAR, + DWORD as _DWORD, + HANDLE as _HANDLE, + SHORT as _SHORT, + UINT as _UINT, + WCHAR as _WCHAR, + WORD as _WORD, +) + +_kernel32 = ctypes.windll.Kernel32 # type: ignore + +_ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 +_ENABLE_PROCESSED_OUTPUT = 0x0001 +_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + +_STD_INPUT_HANDLE = _DWORD(-10) +_STD_OUTPUT_HANDLE = _DWORD(-11) + + +_GetConsoleMode = _kernel32.GetConsoleMode +_GetConsoleMode.restype = _BOOL + +_SetConsoleMode = _kernel32.SetConsoleMode +_SetConsoleMode.restype = _BOOL + +_GetStdHandle = _kernel32.GetStdHandle +_GetStdHandle.restype = _HANDLE + +_ReadConsoleInput = _kernel32.ReadConsoleInputA +_ReadConsoleInput.restype = _BOOL + + +_h_console_input = _GetStdHandle(_STD_INPUT_HANDLE) +_input_mode = _DWORD() +_GetConsoleMode(_HANDLE(_h_console_input), ctypes.byref(_input_mode)) + +_h_console_output = _GetStdHandle(_STD_OUTPUT_HANDLE) +_output_mode = _DWORD() +_GetConsoleMode(_HANDLE(_h_console_output), ctypes.byref(_output_mode)) + + +class _COORD(ctypes.Structure): + _fields_ = (('X', _SHORT), ('Y', _SHORT)) + + +class _FOCUS_EVENT_RECORD(ctypes.Structure): + _fields_ = ('bSetFocus', _BOOL) + + +class _KEY_EVENT_RECORD(ctypes.Structure): + class _uchar(ctypes.Union): + _fields_ = (('UnicodeChar', _WCHAR), ('AsciiChar', _CHAR)) + + _fields_ = ( + ('bKeyDown', _BOOL), + ('wRepeatCount', _WORD), + ('wVirtualKeyCode', _WORD), + ('wVirtualScanCode', _WORD), + ('uChar', _uchar), + ('dwControlKeyState', _DWORD), + ) + + +class _MENU_EVENT_RECORD(ctypes.Structure): + _fields_ = ('dwCommandId', _UINT) + + +class _MOUSE_EVENT_RECORD(ctypes.Structure): + _fields_ = ( + ('dwMousePosition', _COORD), + ('dwButtonState', _DWORD), + ('dwControlKeyState', _DWORD), + ('dwEventFlags', _DWORD), + ) + + +class _WINDOW_BUFFER_SIZE_RECORD(ctypes.Structure): + _fields_ = ('dwSize', _COORD) + + +class _INPUT_RECORD(ctypes.Structure): + class _Event(ctypes.Union): + _fields_ = ( + ('KeyEvent', _KEY_EVENT_RECORD), + ('MouseEvent', _MOUSE_EVENT_RECORD), + ('WindowBufferSizeEvent', _WINDOW_BUFFER_SIZE_RECORD), + ('MenuEvent', _MENU_EVENT_RECORD), + ('FocusEvent', _FOCUS_EVENT_RECORD), + ) + + _fields_ = (('EventType', _WORD), ('Event', _Event)) + + +def reset_console_mode(): + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(_input_mode.value)) + _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(_output_mode.value)) + + +def set_console_mode(): + mode = _input_mode.value | _ENABLE_VIRTUAL_TERMINAL_INPUT + _SetConsoleMode(_HANDLE(_h_console_input), _DWORD(mode)) + + mode = ( + _output_mode.value + | _ENABLE_PROCESSED_OUTPUT + | _ENABLE_VIRTUAL_TERMINAL_PROCESSING + ) + _SetConsoleMode(_HANDLE(_h_console_output), _DWORD(mode)) + + +def getch(): + lp_buffer = (_INPUT_RECORD * 2)() + n_length = _DWORD(2) + lp_number_of_events_read = _DWORD() + + _ReadConsoleInput( + _HANDLE(_h_console_input), + lp_buffer, + n_length, + ctypes.byref(lp_number_of_events_read), + ) + + char = lp_buffer[1].Event.KeyEvent.uChar.AsciiChar.decode('ascii') + if char == '\x00': + return None + + return char diff --git a/progressbar/terminal/stream.py b/progressbar/terminal/stream.py new file mode 100644 index 00000000..ee02a9d9 --- /dev/null +++ b/progressbar/terminal/stream.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import sys +import typing +from types import TracebackType +from typing import Iterable, Iterator + +from progressbar import base + + +class TextIOOutputWrapper(base.TextIO): # pragma: no cover + def __init__(self, stream: base.TextIO): + self.stream = stream + + def close(self) -> None: + self.stream.close() + + def fileno(self) -> int: + return self.stream.fileno() + + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return self.stream.isatty() + + def read(self, __n: int = -1) -> str: + return self.stream.read(__n) + + def readable(self) -> bool: + return self.stream.readable() + + def readline(self, __limit: int = -1) -> str: + return self.stream.readline(__limit) + + def readlines(self, __hint: int = -1) -> list[str]: + return self.stream.readlines(__hint) + + def seek(self, __offset: int, __whence: int = 0) -> int: + return self.stream.seek(__offset, __whence) + + def seekable(self) -> bool: + return self.stream.seekable() + + def tell(self) -> int: + return self.stream.tell() + + def truncate(self, __size: int | None = None) -> int: + return self.stream.truncate(__size) + + def writable(self) -> bool: + return self.stream.writable() + + def writelines(self, __lines: Iterable[str]) -> None: + return self.stream.writelines(__lines) + + def __next__(self) -> str: + return self.stream.__next__() + + def __iter__(self) -> Iterator[str]: + return self.stream.__iter__() + + def __exit__( + self, + __t: type[BaseException] | None, + __value: BaseException | None, + __traceback: TracebackType | None, + ) -> None: + return self.stream.__exit__(__t, __value, __traceback) + + def __enter__(self) -> base.TextIO: + return self.stream.__enter__() + + +class LineOffsetStreamWrapper(TextIOOutputWrapper): + UP = '\033[F' + DOWN = '\033[B' + + def __init__(self, lines=0, stream=sys.stderr): + self.lines = lines + super().__init__(stream) + + def write(self, data): + # Move the cursor up + self.stream.write(self.UP * self.lines) + # Print a carriage return to reset the cursor position + self.stream.write('\r') + # Print the data without newlines so we don't change the position + self.stream.write(data.rstrip('\n')) + # Move the cursor down + self.stream.write(self.DOWN * self.lines) + + self.flush() + + +class LastLineStream(TextIOOutputWrapper): + line: str = '' + + def seekable(self) -> bool: + return False + + def readable(self) -> bool: + return True + + def read(self, __n: int = -1) -> str: + if __n < 0: + return self.line + else: + return self.line[:__n] + + def readline(self, __limit: int = -1) -> str: + if __limit < 0: + return self.line + else: + return self.line[:__limit] + + def write(self, data: str) -> int: + self.line = data + return len(data) + + def truncate(self, __size: int | None = None) -> int: + if __size is None: + self.line = '' + else: + self.line = self.line[:__size] + + return len(self.line) + + def __iter__(self) -> typing.Generator[str, typing.Any, typing.Any]: + yield self.line + + def writelines(self, __lines: Iterable[str]) -> None: + line = '' + # Walk through the lines and take the last one + for line in __lines: # noqa: B007 + pass + + self.line = line diff --git a/progressbar/utils.py b/progressbar/utils.py index 6cfc4bb9..46d0cb27 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import atexit +import contextlib import datetime import io import logging @@ -8,18 +9,19 @@ import re import sys from types import TracebackType -from typing import Iterable, Iterator, Type +from typing import Iterable, Iterator from python_utils import types from python_utils.converters import scale_1024 from python_utils.terminal import get_terminal_size from python_utils.time import epoch, format_time, timedelta_to_seconds -from progressbar import base +from progressbar import base, env, terminal if types.TYPE_CHECKING: from .bar import ProgressBar, ProgressBarMixinBase +# Make sure these are available for import assert timedelta_to_seconds is not None assert get_terminal_size is not None assert format_time is not None @@ -28,82 +30,13 @@ StringT = types.TypeVar('StringT', bound=types.StringTypes) -ANSI_TERMS = ( - '([xe]|bv)term', - '(sco)?ansi', - 'cygwin', - 'konsole', - 'linux', - 'rxvt', - 'screen', - 'tmux', - 'vt(10[02]|220|320)', -) -ANSI_TERM_RE = re.compile('^({})'.format('|'.join(ANSI_TERMS)), re.IGNORECASE) - - -def is_ansi_terminal( - fd: base.IO, is_terminal: bool | None = None -) -> bool: # pragma: no cover - if is_terminal is None: - # Jupyter Notebooks define this variable and support progress bars - if 'JPY_PARENT_PID' in os.environ: - is_terminal = True - # This works for newer versions of pycharm only. older versions there - # is no way to check. - elif os.environ.get('PYCHARM_HOSTED') == '1' and not os.environ.get( - 'PYTEST_CURRENT_TEST' - ): - is_terminal = True - - if is_terminal is None: - # check if we are writing to a terminal or not. typically a file object - # is going to return False if the instance has been overridden and - # isatty has not been defined we have no way of knowing so we will not - # use ansi. ansi terminals will typically define one of the 2 - # environment variables. - try: - is_tty = fd.isatty() - # Try and match any of the huge amount of Linux/Unix ANSI consoles - if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')): - is_terminal = True - # ANSICON is a Windows ANSI compatible console - elif 'ANSICON' in os.environ: - is_terminal = True - else: - is_terminal = None - except Exception: - is_terminal = False - - return bool(is_terminal) - - -def is_terminal(fd: base.IO, is_terminal: bool | None = None) -> bool: - if is_terminal is None: - # Full ansi support encompasses what we expect from a terminal - is_terminal = is_ansi_terminal(fd) or None - - if is_terminal is None: - # Allow a environment variable override - is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None) - - if is_terminal is None: # pragma: no cover - # Bare except because a lot can go wrong on different systems. If we do - # get a TTY we know this is a valid terminal - try: - is_terminal = fd.isatty() - except Exception: - is_terminal = False - - return bool(is_terminal) - def deltas_to_seconds( *deltas, default: types.Optional[types.Type[ValueError]] = ValueError, ) -> int | float | None: ''' - Convert timedeltas and seconds as int to seconds as float while coalescing + Convert timedeltas and seconds as int to seconds as float while coalescing. >>> deltas_to_seconds(datetime.timedelta(seconds=1, milliseconds=234)) 1.234 @@ -145,25 +78,31 @@ def deltas_to_seconds( def no_color(value: StringT) -> StringT: ''' - Return the `value` without ANSI escape codes + Return the `value` without ANSI escape codes. - >>> no_color(b'\u001b[1234]abc') == b'abc' - True + >>> no_color(b'\u001b[1234]abc') + b'abc' >>> str(no_color(u'\u001b[1234]abc')) 'abc' >>> str(no_color('\u001b[1234]abc')) 'abc' + >>> no_color(123) + Traceback (most recent call last): + ... + TypeError: `value` must be a string or bytes, got 123 ''' if isinstance(value, bytes): - pattern: bytes = '\\\u001b\\[.*?[@-~]'.encode() + pattern: bytes = bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]' return re.sub(pattern, b'', value) # type: ignore + elif isinstance(value, str): + return re.sub('\x1b\\[.*?[@-~]', '', value) # type: ignore else: - return re.sub(u'\x1b\\[.*?[@-~]', '', value) # type: ignore + raise TypeError('`value` must be a string or bytes, got %r' % value) def len_color(value: types.StringTypes) -> int: ''' - Return the length of `value` without ANSI escape codes + Return the length of `value` without ANSI escape codes. >>> len_color(b'\u001b[1234]abc') 3 @@ -175,22 +114,6 @@ def len_color(value: types.StringTypes) -> int: return len(no_color(value)) -def env_flag(name: str, default: bool | None = None) -> bool | None: - ''' - Accepts environt variables formatted as y/n, yes/no, 1/0, true/false, - on/off, and returns it as a boolean - - If the environment variable is not defined, or has an unknown value, - returns `default` - ''' - v = os.getenv(name) - if v and v.lower() in ('y', 'yes', 't', 'true', 'on', '1'): - return True - if v and v.lower() in ('n', 'no', 'f', 'false', 'off', '0'): - return False - return default - - class WrappingIO: buffer: io.StringIO target: base.IO @@ -229,8 +152,7 @@ def flush(self) -> None: self.buffer.flush() def _flush(self) -> None: - value = self.buffer.getvalue() - if value: + if value := self.buffer.getvalue(): self.flush() self.target.write(value) self.buffer.seek(0) @@ -241,7 +163,7 @@ def _flush(self) -> None: self.flush_target() def flush_target(self) -> None: # pragma: no cover - if not self.target.closed and getattr(self.target, 'flush'): + if not self.target.closed and getattr(self.target, 'flush', None): self.target.flush() def __enter__(self) -> WrappingIO: @@ -295,7 +217,7 @@ def __iter__(self) -> Iterator[str]: def __exit__( self, - __t: Type[BaseException] | None, + __t: type[BaseException] | None, __value: BaseException | None, __traceback: TracebackType | None, ) -> None: @@ -303,7 +225,7 @@ def __exit__( class StreamWrapper: - '''Wrap stdout and stderr globally''' + '''Wrap stdout and stderr globally.''' stdout: base.TextIO | WrappingIO stderr: base.TextIO | WrappingIO @@ -315,11 +237,6 @@ class StreamWrapper: ], None, ] - # original_excepthook: types.Callable[ - # [ - # types.Type[BaseException], - # BaseException, TracebackType | None, - # ], None] | None wrapped_stdout: int = 0 wrapped_stderr: int = 0 wrapped_excepthook: int = 0 @@ -336,10 +253,10 @@ def __init__(self): self.capturing = 0 self.listeners = set() - if env_flag('WRAP_STDOUT', default=False): # pragma: no cover + if env.env_flag('WRAP_STDOUT', default=False): # pragma: no cover self.wrap_stdout() - if env_flag('WRAP_STDERR', default=False): # pragma: no cover + if env.env_flag('WRAP_STDERR', default=False): # pragma: no cover self.wrap_stderr() def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: @@ -351,10 +268,8 @@ def start_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: def stop_capturing(self, bar: ProgressBarMixinBase | None = None) -> None: if bar: # pragma: no branch - try: + with contextlib.suppress(KeyError): self.listeners.remove(bar) - except KeyError: - pass self.capturing -= 1 self.update_capturing() @@ -381,7 +296,8 @@ def wrap_stdout(self) -> WrappingIO: if not self.wrapped_stdout: self.stdout = sys.stdout = WrappingIO( # type: ignore - self.original_stdout, listeners=self.listeners + self.original_stdout, + listeners=self.listeners, ) self.wrapped_stdout += 1 @@ -392,7 +308,8 @@ def wrap_stderr(self) -> WrappingIO: if not self.wrapped_stderr: self.stderr = sys.stderr = WrappingIO( # type: ignore - self.original_stderr, listeners=self.listeners + self.original_stderr, + listeners=self.listeners, ) self.wrapped_stderr += 1 @@ -436,27 +353,25 @@ def needs_clear(self) -> bool: # pragma: no cover return stderr_needs_clear or stdout_needs_clear def flush(self) -> None: - if self.wrapped_stdout: # pragma: no branch - if isinstance(self.stdout, WrappingIO): # pragma: no branch - try: - self.stdout._flush() - except io.UnsupportedOperation: # pragma: no cover - self.wrapped_stdout = False - logger.warning( - 'Disabling stdout redirection, %r is not seekable', - sys.stdout, - ) - - if self.wrapped_stderr: # pragma: no branch - if isinstance(self.stderr, WrappingIO): # pragma: no branch - try: - self.stderr._flush() - except io.UnsupportedOperation: # pragma: no cover - self.wrapped_stderr = False - logger.warning( - 'Disabling stderr redirection, %r is not seekable', - sys.stderr, - ) + if self.wrapped_stdout and isinstance(self.stdout, WrappingIO): + try: + self.stdout._flush() + except io.UnsupportedOperation: # pragma: no cover + self.wrapped_stdout = False + logger.warning( + 'Disabling stdout redirection, %r is not seekable', + sys.stdout, + ) + + if self.wrapped_stderr and isinstance(self.stderr, WrappingIO): + try: + self.stderr._flush() + except io.UnsupportedOperation: # pragma: no cover + self.wrapped_stderr = False + logger.warning( + 'Disabling stderr redirection, %r is not seekable', + sys.stderr, + ) def excepthook(self, exc_type, exc_value, exc_traceback): self.original_excepthook(exc_type, exc_value, exc_traceback) @@ -465,7 +380,7 @@ def excepthook(self, exc_type, exc_value, exc_traceback): class AttributeDict(dict): ''' - A dict that can be accessed with .attribute + A dict that can be accessed with .attribute. >>> attrs = AttributeDict(spam=123) @@ -513,7 +428,7 @@ def __getattr__(self, name: str) -> int: if name in self: return self[name] else: - raise AttributeError("No such attribute: " + name) + raise AttributeError(f'No such attribute: {name}') def __setattr__(self, name: str, value: int) -> None: self[name] = value @@ -522,7 +437,7 @@ def __delattr__(self, name: str) -> None: if name in self: del self[name] else: - raise AttributeError("No such attribute: " + name) + raise AttributeError(f'No such attribute: {name}') logger = logging.getLogger(__name__) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index b8215bdf..40f29724 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1,20 +1,26 @@ -# -*- coding: utf-8 -*- from __future__ import annotations import abc +import contextlib import datetime import functools -import pprint -import sys +import logging import typing -from python_utils import converters, types +# Ruff is being stupid and doesn't understand `ClassVar` if it comes from the +# `types` module +from typing import ClassVar -from . import base, utils +from python_utils import containers, converters, types + +from . import base, terminal, utils +from .terminal import colors if types.TYPE_CHECKING: from .bar import ProgressBarMixinBase +logger = logging.getLogger(__name__) + MAX_DATE = datetime.date.max MAX_TIME = datetime.time.max MAX_DATETIME = datetime.datetime.max @@ -22,6 +28,8 @@ Data = types.Dict[str, types.Any] FormatString = typing.Optional[str] +T = typing.TypeVar('T') + def string_or_lambda(input_): if isinstance(input_, str): @@ -35,7 +43,7 @@ def render_input(progress, data, width): def create_wrapper(wrapper): - '''Convert a wrapper tuple or format string to a format string + '''Convert a wrapper tuple or format string to a format string. >>> create_wrapper('') @@ -49,14 +57,14 @@ def create_wrapper(wrapper): a, b = wrapper wrapper = (a or '') + '{}' + (b or '') elif not wrapper: - return + return None if isinstance(wrapper, str): assert '{}' in wrapper, 'Expected string with {} for formatting' else: - raise RuntimeError( - 'Pass either a begin/end string as a tuple or a' - ' template string with {}' + raise RuntimeError( # noqa: TRY004 + 'Pass either a begin/end string as a tuple or a template string ' + 'with `{}`', ) return wrapper @@ -64,7 +72,7 @@ def create_wrapper(wrapper): def wrapper(function, wrapper_): '''Wrap the output of a function in a template string or a tuple with - begin/end strings + begin/end strings. ''' wrapper_ = create_wrapper(wrapper_) @@ -100,7 +108,7 @@ def _marker(progress, data, width): class FormatWidgetMixin(abc.ABC): - '''Mixin to format widgets using a formatstring + '''Mixin to format widgets using a formatstring. Variables available: - max_value: The maximum value (can be None with iterators) @@ -133,16 +141,19 @@ def __call__( data: Data, format: types.Optional[str] = None, ) -> str: - '''Formats the widget into a string''' - format = self.get_format(progress, data, format) + '''Formats the widget into a string.''' + format_ = self.get_format(progress, data, format) try: if self.new_style: - return format.format(**data) + return format_.format(**data) else: - return format % data + return format_ % data except (TypeError, KeyError): - print('Error while formatting %r' % format, file=sys.stderr) - pprint.pprint(data, stream=sys.stderr) + logger.exception( + 'Error while formatting %r with data: %r', + format_, + data, + ) raise @@ -176,16 +187,28 @@ def __init__(self, min_width=None, max_width=None, **kwargs): self.max_width = max_width def check_size(self, progress: ProgressBarMixinBase): - if self.min_width and self.min_width > progress.term_width: + max_width = self.max_width + min_width = self.min_width + if min_width and min_width > progress.term_width: return False - elif self.max_width and self.max_width < progress.term_width: + elif max_width and max_width < progress.term_width: # noqa: SIM103 return False else: return True +class TGradientColors(typing.TypedDict): + fg: types.Optional[terminal.OptionalColor | None] + bg: types.Optional[terminal.OptionalColor | None] + + +class TFixedColors(typing.TypedDict): + fg_none: types.Optional[terminal.Color | None] + bg_none: types.Optional[terminal.Color | None] + + class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta): - '''The base class for all widgets + '''The base class for all widgets. The ProgressBar will call the widget's update value when the widget should be updated. The widget's size may change between calls, but the widget may @@ -205,7 +228,7 @@ class WidgetBase(WidthWidgetMixin, metaclass=abc.ABCMeta): Variables available: - min_width: Only display the widget if at least `min_width` is left - max_width: Only display the widget if at most `max_width` is left - - weight: Widgets with a higher `weigth` will be calculated before widgets + - weight: Widgets with a higher `weight` will be calculated before widgets with a lower one - copy: Copy this widget when initializing the progress bar so the progressbar can be reused. Some widgets such as the FormatCustomText @@ -222,6 +245,56 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: progress - a reference to the calling ProgressBar ''' + _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + fg_none=None, + bg_none=None, + ) + _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + fg=None, + bg=None, + ) + # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict() + # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | None]] = ( + # dict()) + _len: typing.Callable[[str | bytes], int] = len + + @functools.cached_property + def uses_colors(self): + for value in self._gradient_colors.values(): # pragma: no branch + if value is not None: # pragma: no branch + return True + + return any(value is not None for value in self._fixed_colors.values()) + + def _apply_colors(self, text: str, data: Data) -> str: + if self.uses_colors: + return terminal.apply_colors( + text, + data.get('percentage'), + **self._gradient_colors, + **self._fixed_colors, + ) + else: + return text + + def __init__( + self, + *args, + fixed_colors=None, + gradient_colors=None, + **kwargs, + ): + if fixed_colors is not None: + self._fixed_colors.update(fixed_colors) + + if gradient_colors is not None: + self._gradient_colors.update(gradient_colors) + + if self.uses_colors: + self._len = utils.len_color + + super().__init__(*args, **kwargs) + class AutoWidthWidgetBase(WidgetBase, metaclass=abc.ABCMeta): '''The base class for all variable width widgets. @@ -256,7 +329,7 @@ class TimeSensitiveWidgetBase(WidgetBase, metaclass=abc.ABCMeta): class FormatLabel(FormatWidgetMixin, WidgetBase): - '''Displays a formatted label + '''Displays a formatted label. >>> label = FormatLabel('%(value)s', min_width=5, max_width=10) >>> class Progress: @@ -267,15 +340,15 @@ class FormatLabel(FormatWidgetMixin, WidgetBase): ''' - mapping = { - 'finished': ('end_time', None), - 'last_update': ('last_update_time', None), - 'max': ('max_value', None), - 'seconds': ('seconds_elapsed', None), - 'start': ('start_time', None), - 'elapsed': ('total_seconds_elapsed', utils.format_time), - 'value': ('value', None), - } + mapping: ClassVar[types.Dict[str, types.Tuple[str, types.Any]]] = dict( + finished=('end_time', None), + last_update=('last_update_time', None), + max=('max_value', None), + seconds=('seconds_elapsed', None), + start=('start_time', None), + elapsed=('total_seconds_elapsed', utils.format_time), + value=('value', None), + ) def __init__(self, format: str, **kwargs): FormatWidgetMixin.__init__(self, format=format, **kwargs) @@ -288,13 +361,11 @@ def __call__( format: types.Optional[str] = None, ): for name, (key, transform) in self.mapping.items(): - try: + with contextlib.suppress(KeyError, ValueError, IndexError): if transform is None: data[name] = data[key] else: data[name] = transform(data[key]) - except (KeyError, ValueError, IndexError): # pragma: no cover - pass return FormatWidgetMixin.__call__(self, progress, data, format) @@ -315,7 +386,7 @@ def __init__(self, format='Elapsed Time: %(elapsed)s', **kwargs): class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): ''' - Mixing for widgets that average multiple measurements + Mixing for widgets that average multiple measurements. Note that samples can be either an integer or a timedelta to indicate a certain amount of time @@ -339,7 +410,7 @@ class SamplesMixin(TimeSensitiveWidgetBase, metaclass=abc.ABCMeta): >>> samples = SamplesMixin(samples=datetime.timedelta(seconds=1)) >>> _, value = samples(progress, None) >>> value - [1, 1] + SliceableDeque([1, 1]) >>> samples(progress, None, True) == (datetime.timedelta(seconds=1), 0) True @@ -352,19 +423,26 @@ def __init__( **kwargs, ): self.samples = samples - self.key_prefix = ( - key_prefix if key_prefix else self.__class__.__name__ - ) + '_' + self.key_prefix = (key_prefix or self.__class__.__name__) + '_' TimeSensitiveWidgetBase.__init__(self, **kwargs) def get_sample_times(self, progress: ProgressBarMixinBase, data: Data): - return progress.extra.setdefault(self.key_prefix + 'sample_times', []) + return progress.extra.setdefault( + f'{self.key_prefix}sample_times', + containers.SliceableDeque(), + ) def get_sample_values(self, progress: ProgressBarMixinBase, data: Data): - return progress.extra.setdefault(self.key_prefix + 'sample_values', []) + return progress.extra.setdefault( + f'{self.key_prefix}sample_values', + containers.SliceableDeque(), + ) def __call__( - self, progress: ProgressBarMixinBase, data: Data, delta: bool = False + self, + progress: ProgressBarMixinBase, + data: Data, + delta: bool = False, ): sample_times = self.get_sample_times(progress, data) sample_values = self.get_sample_values(progress, data) @@ -389,15 +467,13 @@ def __call__( ): sample_times.pop(0) sample_values.pop(0) - else: - if len(sample_times) > self.samples: - sample_times.pop(0) - sample_values.pop(0) + elif len(sample_times) > self.samples: + sample_times.pop(0) + sample_values.pop(0) if delta: - delta_time = sample_times[-1] - sample_times[0] - delta_value = sample_values[-1] - sample_values[0] - if delta_time: + if delta_time := sample_times[-1] - sample_times[0]: + delta_value = sample_values[-1] - sample_values[0] return delta_time, delta_value else: return None, None @@ -414,7 +490,7 @@ def __init__( format_finished='Time: %(elapsed)8s', format='ETA: %(eta)8s', format_zero='ETA: 00:00:00', - format_NA='ETA: N/A', + format_na='ETA: N/A', **kwargs, ): if '%s' in format and '%(eta)s' not in format: @@ -425,21 +501,23 @@ def __init__( self.format_finished = format_finished self.format = format self.format_zero = format_zero - self.format_NA = format_NA + self.format_NA = format_na def _calculate_eta( - self, progress: ProgressBarMixinBase, data: Data, value, elapsed + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): '''Updates the widget to show the ETA or total time when finished.''' if elapsed: # The max() prevents zero division errors per_item = elapsed.total_seconds() / max(value, 1e-6) remaining = progress.max_value - data['value'] - eta_seconds = remaining * per_item + return remaining * per_item else: - eta_seconds = 0 - - return eta_seconds + return 0 def __call__( self, @@ -455,41 +533,46 @@ def __call__( if elapsed is None: elapsed = data['time_elapsed'] - ETA_NA = False + eta_na = False try: data['eta_seconds'] = self._calculate_eta( - progress, data, value=value, elapsed=elapsed + progress, + data, + value=value, + elapsed=elapsed, ) except TypeError: data['eta_seconds'] = None - ETA_NA = True + eta_na = True data['eta'] = None if data['eta_seconds']: - try: + with contextlib.suppress(ValueError, OverflowError): data['eta'] = utils.format_time(data['eta_seconds']) - except (ValueError, OverflowError): # pragma: no cover - pass if data['value'] == progress.min_value: - format = self.format_not_started + fmt = self.format_not_started elif progress.end_time: - format = self.format_finished + fmt = self.format_finished elif data['eta']: - format = self.format - elif ETA_NA: - format = self.format_NA + fmt = self.format + elif eta_na: + fmt = self.format_NA else: - format = self.format_zero + fmt = self.format_zero - return Timer.__call__(self, progress, data, format=format) + return Timer.__call__(self, progress, data, format=fmt) class AbsoluteETA(ETA): '''Widget which attempts to estimate the absolute time of arrival.''' def _calculate_eta( - self, progress: ProgressBarMixinBase, data: Data, value, elapsed + self, + progress: ProgressBarMixinBase, + data: Data, + value, + elapsed, ): eta_seconds = ETA._calculate_eta(self, progress, data, value, elapsed) now = datetime.datetime.now() @@ -533,7 +616,10 @@ def __call__( elapsed=None, ): elapsed, value = SamplesMixin.__call__( - self, progress, data, delta=True + self, + progress, + data, + delta=True, ) if not elapsed: value = None @@ -618,7 +704,8 @@ def __call__( value = data['value'] elapsed = utils.deltas_to_seconds( - total_seconds_elapsed, data['total_seconds_elapsed'] + total_seconds_elapsed, + data['total_seconds_elapsed'], ) if ( @@ -638,7 +725,10 @@ def __call__( data['scaled'] = scaled data['prefix'] = self.prefixes[0] return FormatWidgetMixin.__call__( - self, progress, data, self.inverse_format + self, + progress, + data, + self.inverse_format, ) else: data['scaled'] = scaled @@ -647,7 +737,7 @@ def __call__( class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): - '''Widget for showing the transfer speed based on the last X samples''' + '''Widget for showing the transfer speed based on the last X samples.''' def __init__(self, **kwargs): FileTransferSpeed.__init__(self, **kwargs) @@ -661,7 +751,10 @@ def __call__( total_seconds_elapsed=None, ): elapsed, value = SamplesMixin.__call__( - self, progress, data, delta=True + self, + progress, + data, + delta=True, ) return FileTransferSpeed.__call__(self, progress, data, value, elapsed) @@ -689,8 +782,8 @@ def __init__( def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): '''Updates the widget to show the next marker or the first marker when - finished''' - + finished. + ''' if progress.end_time: return self.default @@ -724,19 +817,38 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): class Counter(FormatWidgetMixin, WidgetBase): - '''Displays the current count''' + '''Displays the current count.''' def __init__(self, format='%(value)d', **kwargs): FormatWidgetMixin.__init__(self, format=format, **kwargs) WidgetBase.__init__(self, format=format, **kwargs) def __call__( - self, progress: ProgressBarMixinBase, data: Data, format=None + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): return FormatWidgetMixin.__call__(self, progress, data, format) -class Percentage(FormatWidgetMixin, WidgetBase): +class ColoredMixin: + _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + fg_none=colors.yellow, + bg_none=None, + ) + _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + fg=colors.gradient, + bg=None, + ) + # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict( + # fg_none=colors.yellow, bg_none=None) + # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | + # None]] = dict(fg=colors.gradient, + # bg=None) + + +class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase): '''Displays the current percentage as a number with a percent sign.''' def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs): @@ -745,18 +857,23 @@ def __init__(self, format='%(percentage)3d%%', na='N/A%%', **kwargs): WidgetBase.__init__(self, format=format, **kwargs) def get_format( - self, progress: ProgressBarMixinBase, data: Data, format=None + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If percentage is not available, display N/A% percentage = data.get('percentage', base.Undefined) if not percentage and percentage != 0: - return self.na + output = self.na + else: + output = FormatWidgetMixin.get_format(self, progress, data, format) - return FormatWidgetMixin.get_format(self, progress, data, format) + return self._apply_colors(output, data) -class SimpleProgress(FormatWidgetMixin, WidgetBase): - '''Returns progress as a count of the total (e.g.: "5 of 47")''' +class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): + '''Returns progress as a count of the total (e.g.: "5 of 47").''' max_width_cache: dict[ types.Union[str, tuple[float, float | types.Type[base.UnknownLength]]], @@ -768,15 +885,17 @@ class SimpleProgress(FormatWidgetMixin, WidgetBase): def __init__(self, format=DEFAULT_FORMAT, **kwargs): FormatWidgetMixin.__init__(self, format=format, **kwargs) WidgetBase.__init__(self, format=format, **kwargs) - self.max_width_cache = dict() - self.max_width_cache['default'] = self.max_width or 0 + self.max_width_cache = dict(default=self.max_width or 0) def __call__( - self, progress: ProgressBarMixinBase, data: Data, format=None + self, + progress: ProgressBarMixinBase, + data: Data, + format=None, ): # If max_value is not available, display N/A if data.get('max_value'): - data['max_value_s'] = data.get('max_value') + data['max_value_s'] = data['max_value'] else: data['max_value_s'] = 'N/A' @@ -787,13 +906,17 @@ def __call__( data['value_s'] = 0 formatted = FormatWidgetMixin.__call__( - self, progress, data, format=format + self, + progress, + data, + format=format, ) # Guess the maximum width from the min and max value key = progress.min_value, progress.max_value max_width: types.Optional[int] = self.max_width_cache.get( - key, self.max_width + key, + self.max_width, ) if not max_width: temporary_data = data.copy() @@ -802,12 +925,14 @@ def __call__( continue temporary_data['value'] = value - width = progress.custom_len( + if width := progress.custom_len( # pragma: no branch FormatWidgetMixin.__call__( - self, progress, temporary_data, format=format - ) - ) - if width: # pragma: no branch + self, + progress, + temporary_data, + format=format, + ), + ): max_width = max(max_width or 0, width) self.max_width_cache[key] = max_width @@ -816,12 +941,15 @@ def __call__( if max_width: # pragma: no branch formatted = formatted.rjust(max_width) - return formatted + return self._apply_colors(formatted, data) class Bar(AutoWidthWidgetBase): '''A progress bar which stretches to fill the line.''' + fg: terminal.OptionalColor | None = colors.gradient + bg: terminal.OptionalColor | None = None + def __init__( self, marker='#', @@ -842,7 +970,6 @@ def __init__( fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right ''' - self.marker = create_marker(marker, marker_wrap) self.left = string_or_lambda(left) self.right = string_or_lambda(right) @@ -856,9 +983,9 @@ def __call__( progress: ProgressBarMixinBase, data: Data, width: int = 0, + color=True, ): - '''Updates the progress bar and its subcomponents''' - + '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) width -= progress.custom_len(left) + progress.custom_len(right) @@ -873,11 +1000,14 @@ def __call__( else: marker = marker.rjust(width, fill) + if color: + marker = self._apply_colors(marker, data) + return left + marker + right class ReverseBar(Bar): - '''A bar which has a marker that goes from right to left''' + '''A bar which has a marker that goes from right to left.''' def __init__( self, @@ -917,9 +1047,9 @@ def __call__( progress: ProgressBarMixinBase, data: Data, width: int = 0, + color=True, ): - '''Updates the progress bar and its subcomponents''' - + '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) width -= progress.custom_len(left) + progress.custom_len(right) @@ -929,7 +1059,7 @@ def __call__( if width: # pragma: no branch value = int( - data['total_seconds_elapsed'] / self.INTERVAL.total_seconds() + data['total_seconds_elapsed'] / self.INTERVAL.total_seconds(), ) a = value % width @@ -946,7 +1076,7 @@ def __call__( class FormatCustomText(FormatWidgetMixin, WidgetBase): - mapping: types.Dict[str, types.Any] = {} + mapping: types.Dict[str, types.Any] = dict() # noqa: RUF012 copy = False def __init__( @@ -970,12 +1100,15 @@ def __call__( format: types.Optional[str] = None, ): return FormatWidgetMixin.__call__( - self, progress, self.mapping, format or self.format + self, + progress, + self.mapping, + format or self.format, ) class VariableMixin: - '''Mixin to display a custom user variable''' + '''Mixin to display a custom user variable.''' def __init__(self, name, **kwargs): if not isinstance(name, str): @@ -987,7 +1120,7 @@ def __init__(self, name, **kwargs): class MultiRangeBar(Bar, VariableMixin): ''' - A bar with multiple sub-ranges, each represented by a different symbol + A bar with multiple sub-ranges, each represented by a different symbol. The various ranges are represented on a user-defined variable, formatted as @@ -1013,9 +1146,9 @@ def __call__( progress: ProgressBarMixinBase, data: Data, width: int = 0, + color=True, ): - '''Updates the progress bar and its subcomponents''' - + '''Updates the progress bar and its subcomponents.''' left = converters.to_unicode(self.left(progress, data, width)) right = converters.to_unicode(self.right(progress, data, width)) width -= progress.custom_len(left) + progress.custom_len(right) @@ -1069,8 +1202,7 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data): if not 0 <= value <= 1: raise ValueError( - 'Range value needs to be in the range [0..1], got %s' - % value + f'Range value needs to be in the range [0..1], got {value}', ) range_ = value * (len(ranges) - 1) @@ -1080,7 +1212,7 @@ def get_values(self, progress: ProgressBarMixinBase, data: Data): if frac: ranges[pos + 1] += frac - if self.fill_left: + if self.fill_left: # pragma: no branch ranges = list(reversed(ranges)) return ranges @@ -1157,8 +1289,7 @@ def __call__( marker = self.markers[-1] * int(num_chars) - marker_idx = int((num_chars % 1) * (len(self.markers) - 1)) - if marker_idx: + if marker_idx := int((num_chars % 1) * (len(self.markers) - 1)): marker += self.markers[marker_idx] marker = converters.to_unicode(marker) @@ -1185,13 +1316,27 @@ def __call__( # type: ignore format: FormatString = None, ): center = FormatLabel.__call__(self, progress, data, format=format) - bar = Bar.__call__(self, progress, data, width) + bar = Bar.__call__(self, progress, data, width, color=False) # Aligns the center of the label to the center of the bar center_len = progress.custom_len(center) center_left = int((width - center_len) / 2) center_right = center_left + center_len - return bar[:center_left] + center + bar[center_right:] + + return ( + self._apply_colors( + bar[:center_left], + data, + ) + + self._apply_colors( + center, + data, + ) + + self._apply_colors( + bar[center_right:], + data, + ) + ) class PercentageLabelBar(Percentage, FormatLabelBar): @@ -1253,7 +1398,7 @@ def __call__( except (TypeError, ValueError): if value: context['formatted_value'] = '{value:{width}}'.format( - **context + **context, ) else: context['formatted_value'] = '-' * self.width @@ -1264,8 +1409,6 @@ def __call__( class DynamicMessage(Variable): '''Kept for backwards compatibility, please use `Variable` instead.''' - pass - class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): '''Widget which displays the current (date)time with seconds resolution.''' @@ -1302,3 +1445,120 @@ def current_datetime(self): def current_time(self): return self.current_datetime().time() + + +class JobStatusBar(Bar, VariableMixin): + ''' + Widget which displays the job status as markers on the bar. + + The status updates can be given either as a boolean or as a string. If it's + a string, it will be displayed as-is. If it's a boolean, it will be + displayed as a marker (default: '█' for success, 'X' for failure) + configurable through the `success_marker` and `failure_marker` parameters. + + Args: + name: The name of the variable to use for the status updates. + left: The left border of the bar. + right: The right border of the bar. + fill: The fill character of the bar. + fill_left: Whether to fill the bar from the left or the right. + success_fg_color: The foreground color to use for successful jobs. + success_bg_color: The background color to use for successful jobs. + success_marker: The marker to use for successful jobs. + failure_fg_color: The foreground color to use for failed jobs. + failure_bg_color: The background color to use for failed jobs. + failure_marker: The marker to use for failed jobs. + ''' + + success_fg_color: terminal.Color | None = colors.green + success_bg_color: terminal.Color | None = None + success_marker: str = '█' + failure_fg_color: terminal.Color | None = colors.red + failure_bg_color: terminal.Color | None = None + failure_marker: str = 'X' + job_markers: list[str] + + def __init__( + self, + name: str, + left='|', + right='|', + fill=' ', + fill_left=True, + success_fg_color=colors.green, + success_bg_color=None, + success_marker='█', + failure_fg_color=colors.red, + failure_bg_color=None, + failure_marker='X', + **kwargs, + ): + VariableMixin.__init__(self, name) + self.name = name + self.job_markers = [] + self.left = string_or_lambda(left) + self.right = string_or_lambda(right) + self.fill = string_or_lambda(fill) + self.success_fg_color = success_fg_color + self.success_bg_color = success_bg_color + self.success_marker = success_marker + self.failure_fg_color = failure_fg_color + self.failure_bg_color = failure_bg_color + self.failure_marker = failure_marker + + Bar.__init__( + self, + left=left, + right=right, + fill=fill, + fill_left=fill_left, + **kwargs, + ) + + def __call__( + self, + progress: ProgressBarMixinBase, + data: Data, + width: int = 0, + color=True, + ): + left = converters.to_unicode(self.left(progress, data, width)) + right = converters.to_unicode(self.right(progress, data, width)) + width -= progress.custom_len(left) + progress.custom_len(right) + + status: str | bool | None = data['variables'].get(self.name) + + if width and status is not None: + if status is True: + marker = self.success_marker + fg_color = self.success_fg_color + bg_color = self.success_bg_color + elif status is False: # pragma: no branch + marker = self.failure_marker + fg_color = self.failure_fg_color + bg_color = self.failure_bg_color + else: # pragma: no cover + marker = status + fg_color = bg_color = None + + marker = converters.to_unicode(marker) + if fg_color: # pragma: no branch + marker = fg_color.fg(marker) + if bg_color: # pragma: no cover + marker = bg_color.bg(marker) + + self.job_markers.append(marker) + marker = ''.join(self.job_markers) + width -= progress.custom_len(marker) + + fill = converters.to_unicode(self.fill(progress, data, width)) + fill = self._apply_colors(fill * width, data) + + if self.fill_left: # pragma: no branch + marker += fill + else: # pragma: no cover + marker = fill + marker + else: + marker = '' + + return left + marker + right diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..e026134c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,190 @@ + +[project] +authors = [{ name = 'Rick van Hattem (Wolph)', email = 'wolph@wol.ph' }] +dynamic = ['version'] +keywords = [ + 'REPL', + 'animated', + 'bar', + 'color', + 'console', + 'duration', + 'efficient', + 'elapsed', + 'eta', + 'feedback', + 'live', + 'meter', + 'monitor', + 'monitoring', + 'multi-threaded', + 'progress', + 'progress-bar', + 'progressbar', + 'progressmeter', + 'python', + 'rate', + 'simple', + 'speed', + 'spinner', + 'stats', + 'terminal', + 'throughput', + 'time', + 'visual', +] +license = { text = 'BSD-3-Clause' } +name = 'progressbar2' +requires-python = '>=3.8' + +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Development Status :: 6 - Mature', + 'Environment :: Console', + 'Environment :: MacOS X', + 'Environment :: Other Environment', + 'Environment :: Win32 (MS Windows)', + 'Environment :: X11 Applications', + 'Framework :: IPython', + 'Framework :: Jupyter', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Other Audience', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: MacOS', + 'Operating System :: Microsoft :: MS-DOS', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: Microsoft', + 'Operating System :: POSIX :: BSD :: FreeBSD', + 'Operating System :: POSIX :: BSD', + 'Operating System :: POSIX :: Linux', + 'Operating System :: POSIX :: SunOS/Solaris', + 'Operating System :: POSIX', + 'Operating System :: Unix', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: Implementation :: IronPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Programming Language :: Python :: Implementation', + 'Programming Language :: Python', + 'Programming Language :: Unix Shell', + 'Topic :: Desktop Environment', + 'Topic :: Education :: Computer Aided Instruction (CAI)', + 'Topic :: Education :: Testing', + 'Topic :: Office/Business', + 'Topic :: Other/Nonlisted Topic', + 'Topic :: Software Development :: Build Tools', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Pre-processors', + 'Topic :: Software Development :: User Interfaces', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Logging', + 'Topic :: System :: Monitoring', + 'Topic :: System :: Shells', + 'Topic :: Terminals', + 'Topic :: Utilities', +] +description = 'A Python Progressbar library to provide visual (yet text based) progress to long running operations.' +readme = 'README.rst' + +dependencies = ['python-utils >= 3.8.1'] + +[tool.setuptools.dynamic] +version = { attr = 'progressbar.__about__.__version__' } + +[tool.setuptools.packages.find] +exclude = ['docs', 'tests'] + +[tool.setuptools] +include-package-data = true + +[project.scripts] +cli-name = 'progressbar.cli:main' + +[project.optional-dependencies] +docs = ['sphinx>=1.8.5', 'sphinx-autodoc-typehints>=1.6.0'] +tests = [ + 'dill>=0.3.6', + 'flake8>=3.7.7', + 'freezegun>=0.3.11', + 'pytest-cov>=2.6.1', + 'pytest-mypy', + 'pytest>=4.6.9', + 'sphinx>=1.8.5', +] + +[project.urls] +bugs = 'https://github.com/wolph/python-progressbar/issues' +documentation = 'https://progressbar-2.readthedocs.io/en/latest/' +repository = 'https://github.com/wolph/python-progressbar/' + +[build-system] +build-backend = 'setuptools.build_meta' +requires = ['setuptools', 'setuptools-scm'] + +[tool.codespell] +skip = '*/htmlcov,./docs/_build,*.asc' + +ignore-words-list = 'datas,numbert' + +[tool.black] +line-length = 79 +skip-string-normalization = true + +[tool.mypy] +packages = ['progressbar', 'tests'] +exclude = [ + '^docs$', + '^tests/original_examples.py$', + '^examples.py$', +] + +[tool.coverage.run] +branch = true +source = [ + 'progressbar', + 'tests', +] +omit = [ + '*/mock/*', + '*/nose/*', + '.tox/*', + '*/os_specific/*', +] +[tool.coverage.paths] +source = [ + 'progressbar', +] + +[tool.coverage.report] +fail_under = 100 +exclude_lines = [ + 'pragma: no cover', + '@abc.abstractmethod', + 'def __repr__', + 'if self.debug:', + 'if settings.DEBUG', + 'raise AssertionError', + 'raise NotImplementedError', + 'if 0:', + 'if __name__ == .__main__.:', + 'if types.TYPE_CHECKING:', + '@typing.overload', +] + +[tool.pyright] +include= ['progressbar'] +exclude= ['examples'] +ignore= ['docs'] + +reportIncompatibleMethodOverride = false \ No newline at end of file diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 58d8fa22..00000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "include": [ - "progressbar" - ], - "exclude": [ - "examples" - ], - "ignore": [ - "docs" - ], -} diff --git a/pytest.ini b/pytest.ini index bdfd4dec..08a11301 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,20 +5,21 @@ python_files = addopts = --cov progressbar - --cov-report term-missing - --cov-report html + --cov-report=html + --cov-report=term-missing + --cov-report=xml + --cov-config=./pyproject.toml --no-cov-on-fail --doctest-modules norecursedirs = - .svn - _build - tmp* - docs + .* + _* build dist - .ropeproject - .tox + docs + progressbar/terminal/os_specific + tmp* filterwarnings = ignore::DeprecationWarning diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..083e321e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,75 @@ +# We keep the ruff configuration separate so it can easily be shared across +# all projects + +target-version = 'py38' + +src = ['progressbar'] + +ignore = [ + 'A001', # Variable {name} is shadowing a Python builtin + 'A002', # Argument {name} is shadowing a Python builtin + 'A003', # Class attribute {name} is shadowing a Python builtin + 'B023', # function-uses-loop-variable + 'B024', # `FormatWidgetMixin` is an abstract base class, but it has no abstract methods + 'D205', # blank-line-after-summary + 'D212', # multi-line-summary-first-line + 'RET505', # Unnecessary `else` after `return` statement + 'TRY003', # Avoid specifying long messages outside the exception class + 'RET507', # Unnecessary `elif` after `continue` statement + 'C405', # Unnecessary {obj_type} literal (rewrite as a set literal) + 'C406', # Unnecessary {obj_type} literal (rewrite as a dict literal) + 'C408', # Unnecessary {obj_type} call (rewrite as a literal) + 'SIM114', # Combine `if` branches using logical `or` operator + 'RET506', # Unnecessary `else` after `raise` statement +] +line-length = 80 +select = [ + 'A', # flake8-builtins + 'ASYNC', # flake8 async checker + 'B', # flake8-bugbear + 'C4', # flake8-comprehensions + 'C90', # mccabe + 'COM', # flake8-commas + + ## Require docstrings for all public methods, would be good to enable at some point + # 'D', # pydocstyle + + 'E', # pycodestyle error ('W' for warning) + 'F', # pyflakes + 'FA', # flake8-future-annotations + 'I', # isort + 'ICN', # flake8-import-conventions + 'INP', # flake8-no-pep420 + 'ISC', # flake8-implicit-str-concat + 'N', # pep8-naming + 'NPY', # NumPy-specific rules + 'PERF', # perflint, + 'PIE', # flake8-pie + 'Q', # flake8-quotes + + 'RET', # flake8-return + 'RUF', # Ruff-specific rules + 'SIM', # flake8-simplify + 'T20', # flake8-print + 'TD', # flake8-todos + 'TRY', # tryceratops + 'UP', # pyupgrade +] + +[per-file-ignores] +'tests/*' = ['INP001', 'T201', 'T203'] +'examples.py' = ['T201'] + +[pydocstyle] +convention = 'google' +ignore-decorators = ['typing.overload'] + +[isort] +case-sensitive = true +combine-as-imports = true +force-wrap-aliases = true + +[flake8-quotes] +docstring-quotes = 'single' +inline-quotes = 'single' +multiline-quotes = 'single' diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a67b32e4..00000000 --- a/setup.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[metadata] -description-file = README.rst - -[bdist_wheel] -universal = 1 - -[upload] -sign = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 850df2de..00000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import sys - -from setuptools import setup, find_packages - -# To prevent importing about and thereby breaking the coverage info we use this -# exec hack -about = {} -with open('progressbar/__about__.py', encoding='utf8') as fp: - exec(fp.read(), about) - - -install_reqs = [] -if sys.argv[-1] == 'info': - for k, v in about.items(): - print('%s: %s' % (k, v)) - sys.exit() - -if os.path.isfile('README.rst'): - with open('README.rst') as fh: - readme = fh.read() -else: - readme = \ - 'See http://pypi.python.org/pypi/%(__package_name__)s/' % about - -if __name__ == '__main__': - setup( - name='progressbar2', - version=about['__version__'], - author=about['__author__'], - author_email=about['__email__'], - description=about['__description__'], - url=about['__url__'], - license=about['__license__'], - keywords=about['__title__'], - packages=find_packages(exclude=['docs']), - long_description=readme, - include_package_data=True, - install_requires=[ - 'python-utils>=3.4.5', - ], - setup_requires=['setuptools'], - zip_safe=False, - extras_require={ - 'docs': [ - 'sphinx>=1.8.5', - ], - 'tests': [ - 'flake8>=3.7.7', - 'pytest>=4.6.9', - 'pytest-cov>=2.6.1', - 'pytest-mypy', - 'freezegun>=0.3.11', - 'sphinx>=1.8.5', - 'dill>=0.3.6', - ], - }, - python_requires='>=3.7.0', - classifiers=[ - 'Development Status :: 6 - Mature', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: Implementation :: PyPy', - ], - ) diff --git a/tests/conftest.py b/tests/conftest.py index 88832759..6a53b802 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ +import logging import time import timeit -import pytest -import logging -import freezegun -import progressbar from datetime import datetime +import freezegun +import progressbar +import pytest LOG_LEVELS = { '0': logging.ERROR, @@ -17,21 +17,25 @@ def pytest_configure(config): logging.basicConfig( - level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG)) + level=LOG_LEVELS.get(config.option.verbose, logging.DEBUG), + ) @pytest.fixture(autouse=True) def small_interval(monkeypatch): # Remove the update limit for tests by default monkeypatch.setattr( - progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 1e-6) + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 1e-6, + ) monkeypatch.setattr(timeit, 'default_timer', time.time) @pytest.fixture(autouse=True) def sleep_faster(monkeypatch): # The timezone offset in seconds, add 10 seconds to make sure we don't - # accidently get the wrong hour + # accidentally get the wrong hour offset_seconds = (datetime.now() - datetime.utcnow()).seconds + 10 offset_hours = int(offset_seconds / 3600) diff --git a/tests/original_examples.py b/tests/original_examples.py index 2e521e9d..7f745d03 100644 --- a/tests/original_examples.py +++ b/tests/original_examples.py @@ -1,18 +1,34 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- import sys import time -from progressbar import AnimatedMarker, Bar, BouncingBar, Counter, ETA, \ - AdaptiveETA, FileTransferSpeed, FormatLabel, Percentage, \ - ProgressBar, ReverseBar, RotatingMarker, \ - SimpleProgress, Timer, UnknownLength +from progressbar import ( + ETA, + AdaptiveETA, + AnimatedMarker, + Bar, + BouncingBar, + Counter, + FileTransferSpeed, + FormatLabel, + Percentage, + ProgressBar, + ReverseBar, + RotatingMarker, + SimpleProgress, + Timer, + UnknownLength, +) examples = [] + + def example(fn): - try: name = 'Example %d' % int(fn.__name__[7:]) - except: name = fn.__name__ + try: + name = 'Example %d' % int(fn.__name__[7:]) + except Exception: + name = fn.__name__ def wrapped(): try: @@ -25,65 +41,94 @@ def wrapped(): examples.append(wrapped) return wrapped + @example def example0(): pbar = ProgressBar(widgets=[Percentage(), Bar()], maxval=300).start() for i in range(300): time.sleep(0.01) - pbar.update(i+1) + pbar.update(i + 1) pbar.finish() + @example def example1(): - widgets = ['Test: ', Percentage(), ' ', Bar(marker=RotatingMarker()), - ' ', ETA(), ' ', FileTransferSpeed()] + widgets = [ + 'Test: ', + Percentage(), + ' ', + Bar(marker=RotatingMarker()), + ' ', + ETA(), + ' ', + FileTransferSpeed(), + ] pbar = ProgressBar(widgets=widgets, maxval=10000).start() for i in range(1000): # do something - pbar.update(10*i+1) + pbar.update(10 * i + 1) pbar.finish() + @example def example2(): class CrazyFileTransferSpeed(FileTransferSpeed): """It's bigger between 45 and 80 percent.""" + def update(self, pbar): if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + FileTransferSpeed.update(self,pbar) + return 'Bigger Now ' + FileTransferSpeed.update(self, pbar) else: - return FileTransferSpeed.update(self,pbar) + return FileTransferSpeed.update(self, pbar) - widgets = [CrazyFileTransferSpeed(),' <<<', Bar(), '>>> ', - Percentage(),' ', ETA()] + widgets = [ + CrazyFileTransferSpeed(), + ' <<<', + Bar(), + '>>> ', + Percentage(), + ' ', + ETA(), + ] pbar = ProgressBar(widgets=widgets, maxval=10000) # maybe do something pbar.start() for i in range(2000): # do something - pbar.update(5*i+1) + pbar.update(5 * i + 1) pbar.finish() + @example def example3(): widgets = [Bar('>'), ' ', ETA(), ' ', ReverseBar('<')] pbar = ProgressBar(widgets=widgets, maxval=10000).start() for i in range(1000): # do something - pbar.update(10*i+1) + pbar.update(10 * i + 1) pbar.finish() + @example def example4(): - widgets = ['Test: ', Percentage(), ' ', - Bar(marker='0',left='[',right=']'), - ' ', ETA(), ' ', FileTransferSpeed()] + widgets = [ + 'Test: ', + Percentage(), + ' ', + Bar(marker='0', left='[', right=']'), + ' ', + ETA(), + ' ', + FileTransferSpeed(), + ] pbar = ProgressBar(widgets=widgets, maxval=500) pbar.start() - for i in range(100,500+1,50): + for i in range(100, 500 + 1, 50): time.sleep(0.2) pbar.update(i) pbar.finish() + @example def example5(): pbar = ProgressBar(widgets=[SimpleProgress()], maxval=17).start() @@ -92,6 +137,7 @@ def example5(): pbar.update(i + 1) pbar.finish() + @example def example6(): pbar = ProgressBar().start() @@ -100,54 +146,63 @@ def example6(): pbar.update(i + 1) pbar.finish() + @example def example7(): pbar = ProgressBar() # Progressbar can guess maxval automatically. - for i in pbar(range(80)): + for _i in pbar(range(80)): time.sleep(0.01) + @example def example8(): pbar = ProgressBar(maxval=80) # Progressbar can't guess maxval. - for i in pbar((i for i in range(80))): + for _i in pbar(i for i in range(80)): time.sleep(0.01) + @example def example9(): pbar = ProgressBar(widgets=['Working: ', AnimatedMarker()]) - for i in pbar((i for i in range(50))): - time.sleep(.08) + for _i in pbar(i for i in range(50)): + time.sleep(0.08) + @example def example10(): widgets = ['Processed: ', Counter(), ' lines (', Timer(), ')'] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(150))): + for _i in pbar(i for i in range(150)): time.sleep(0.1) + @example def example11(): widgets = [FormatLabel('Processed: %(value)d lines (in: %(elapsed)s)')] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(150))): + for _i in pbar(i for i in range(150)): time.sleep(0.1) + @example def example12(): widgets = ['Balloon: ', AnimatedMarker(markers='.oO@* ')] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): + for _i in pbar(i for i in range(24)): time.sleep(0.3) + @example def example13(): # You may need python 3.x to see this correctly try: widgets = ['Arrows: ', AnimatedMarker(markers='←↖↑↗→↘↓↙')] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): + for _i in pbar(i for i in range(24)): time.sleep(0.3) - except UnicodeError: sys.stdout.write('Unicode error: skipping example') + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + @example def example14(): @@ -155,9 +210,11 @@ def example14(): try: widgets = ['Arrows: ', AnimatedMarker(markers='◢◣◤◥')] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): + for _i in pbar(i for i in range(24)): time.sleep(0.3) - except UnicodeError: sys.stdout.write('Unicode error: skipping example') + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + @example def example15(): @@ -165,32 +222,35 @@ def example15(): try: widgets = ['Wheels: ', AnimatedMarker(markers='◐◓◑◒')] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(24))): + for _i in pbar(i for i in range(24)): time.sleep(0.3) - except UnicodeError: sys.stdout.write('Unicode error: skipping example') + except UnicodeError: + sys.stdout.write('Unicode error: skipping example') + @example def example16(): widgets = [FormatLabel('Bouncer: value %(value)d - '), BouncingBar()] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(180))): + for _i in pbar(i for i in range(180)): time.sleep(0.05) + @example def example17(): - widgets = [FormatLabel('Animated Bouncer: value %(value)d - '), - BouncingBar(marker=RotatingMarker())] + widgets = [ + FormatLabel('Animated Bouncer: value %(value)d - '), + BouncingBar(marker=RotatingMarker()), + ] pbar = ProgressBar(widgets=widgets) - for i in pbar((i for i in range(180))): + for _i in pbar(i for i in range(180)): time.sleep(0.05) + @example def example18(): - widgets = [Percentage(), - ' ', Bar(), - ' ', ETA(), - ' ', AdaptiveETA()] + widgets = [Percentage(), ' ', Bar(), ' ', ETA(), ' ', AdaptiveETA()] pbar = ProgressBar(widgets=widgets, maxval=500) pbar.start() for i in range(500): @@ -198,21 +258,29 @@ def example18(): pbar.update(i + 1) pbar.finish() + @example def example19(): - pbar = ProgressBar() - for i in pbar([]): - pass - pbar.finish() + pbar = ProgressBar() + for _i in pbar([]): + pass + pbar.finish() + @example def example20(): - """Widgets that behave differently when length is unknown""" - widgets = ['[When length is unknown at first]', - ' Progress: ', SimpleProgress(), - ', Percent: ', Percentage(), - ' ', ETA(), - ' ', AdaptiveETA()] + '''Widgets that behave differently when length is unknown''' + widgets = [ + '[When length is unknown at first]', + ' Progress: ', + SimpleProgress(), + ', Percent: ', + Percentage(), + ' ', + ETA(), + ' ', + AdaptiveETA(), + ] pbar = ProgressBar(widgets=widgets, maxval=UnknownLength) pbar.start() for i in range(20): @@ -222,8 +290,10 @@ def example20(): pbar.update(i + 1) pbar.finish() + if __name__ == '__main__': try: - for example in examples: example() + for example in examples: + example() except KeyboardInterrupt: sys.stdout.write('\nQuitting examples.\n') diff --git a/tests/test_backwards_compatibility.py b/tests/test_backwards_compatibility.py index 027c3f9e..1f9a7a6e 100644 --- a/tests/test_backwards_compatibility.py +++ b/tests/test_backwards_compatibility.py @@ -1,12 +1,13 @@ import time + import progressbar def test_progressbar_1_widgets(): widgets = [ - progressbar.AdaptiveETA(format="Time left: %s"), - progressbar.Timer(format="Time passed: %s"), - progressbar.Bar() + progressbar.AdaptiveETA(format='Time left: %s'), + progressbar.Timer(format='Time passed: %s'), + progressbar.Bar(), ] bar = progressbar.ProgressBar(widgets=widgets, max_value=100).start() diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 00000000..1a6657e6 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import typing + +import progressbar +import progressbar.env +import progressbar.terminal +import pytest +from progressbar import env, terminal, widgets +from progressbar.terminal import Colors, apply_colors, colors + + +@pytest.mark.parametrize( + 'variable', + [ + 'PROGRESSBAR_ENABLE_COLORS', + 'FORCE_COLOR', + ], +) +def test_color_environment_variables(monkeypatch, variable): + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + progressbar.env.ColorSupport.XTERM_256, + ) + + monkeypatch.setenv(variable, '1') + bar = progressbar.ProgressBar() + assert bar.enable_colors + + monkeypatch.setenv(variable, '0') + bar = progressbar.ProgressBar() + assert not bar.enable_colors + + +@pytest.mark.parametrize( + 'variable', + [ + 'FORCE_COLOR', + 'PROGRESSBAR_ENABLE_COLORS', + 'COLORTERM', + 'TERM', + ], +) +@pytest.mark.parametrize( + 'value', + [ + '', + 'truecolor', + '24bit', + '256', + 'xterm-256', + 'xterm', + ], +) +def test_color_support_from_env(monkeypatch, variable, value): + monkeypatch.setenv('JUPYTER_COLUMNS', '') + monkeypatch.setenv('JUPYTER_LINES', '') + + monkeypatch.setenv(variable, value) + progressbar.env.ColorSupport.from_env() + + +@pytest.mark.parametrize( + 'variable', + [ + 'JUPYTER_COLUMNS', + 'JUPYTER_LINES', + ], +) +def test_color_support_from_env_jupyter(monkeypatch, variable): + monkeypatch.setenv(variable, '80') + progressbar.env.ColorSupport.from_env() + + +def test_enable_colors_flags(): + bar = progressbar.ProgressBar(enable_colors=True) + assert bar.enable_colors + + bar = progressbar.ProgressBar(enable_colors=False) + assert not bar.enable_colors + + bar = progressbar.ProgressBar( + enable_colors=progressbar.env.ColorSupport.XTERM_TRUECOLOR, + ) + assert bar.enable_colors + + with pytest.raises(ValueError): + progressbar.ProgressBar(enable_colors=12345) + + +class _TestFixedColorSupport(progressbar.widgets.WidgetBase): + _fixed_colors: typing.ClassVar[ + widgets.TFixedColors + ] = widgets.TFixedColors( + fg_none=progressbar.widgets.colors.yellow, + bg_none=None, + ) + + def __call__(self, *args, **kwargs): + pass + + +class _TestFixedGradientSupport(progressbar.widgets.WidgetBase): + _gradient_colors: typing.ClassVar[ + widgets.TGradientColors + ] = widgets.TGradientColors( + fg=progressbar.widgets.colors.gradient, + bg=None, + ) + + def __call__(self, *args, **kwargs): + pass + + +@pytest.mark.parametrize( + 'widget', + [ + progressbar.Percentage, + progressbar.SimpleProgress, + _TestFixedColorSupport, + _TestFixedGradientSupport, + ], +) +def test_color_widgets(widget): + assert widget().uses_colors + print(f'{widget} has colors? {widget.uses_colors}') + + +def test_color_gradient(): + gradient = terminal.ColorGradient(colors.red) + assert gradient.get_color(0) == gradient.get_color(-1) + assert gradient.get_color(1) == gradient.get_color(2) + + assert gradient.get_color(0.5) == colors.red + + gradient = terminal.ColorGradient(colors.red, colors.yellow) + assert gradient.get_color(0) == colors.red + assert gradient.get_color(1) == colors.yellow + assert gradient.get_color(0.5) != colors.red + assert gradient.get_color(0.5) != colors.yellow + + gradient = terminal.ColorGradient( + colors.red, colors.yellow, interpolate=False, + ) + assert gradient.get_color(0) == colors.red + assert gradient.get_color(1) == colors.yellow + assert gradient.get_color(0.5) == colors.red + + +@pytest.mark.parametrize( + 'widget', + [ + progressbar.Counter, + ], +) +def test_no_color_widgets(widget): + assert not widget().uses_colors + print(f'{widget} has colors? {widget.uses_colors}') + + assert widget( + fixed_colors=_TestFixedColorSupport._fixed_colors, + ).uses_colors + assert widget( + gradient_colors=_TestFixedGradientSupport._gradient_colors, + ).uses_colors + + +def test_colors(): + for colors_ in Colors.by_rgb.values(): + for color in colors_: + rgb = color.rgb + assert rgb.rgb + assert rgb.hex + assert rgb.to_ansi_16 is not None + assert rgb.to_ansi_256 is not None + assert color.underline + assert color.fg + assert color.bg + assert str(color) + assert str(rgb) + + +def test_color(): + color = colors.red + assert color('x') == color.fg('x') != 'x' + assert color.fg('x') != color.bg('x') != 'x' + assert color.fg('x') != color.underline('x') != 'x' + # Color hashes are based on the RGB value + assert hash(color) == hash(terminal.Color(color.rgb, None, None, None)) + Colors.register(color.rgb) + + +@pytest.mark.parametrize( + 'rgb,hls', + [ + (terminal.RGB(0, 0, 0), terminal.HSL(0, 0, 0)), + (terminal.RGB(255, 255, 255), terminal.HSL(0, 0, 100)), + (terminal.RGB(255, 0, 0), terminal.HSL(0, 100, 50)), + (terminal.RGB(0, 255, 0), terminal.HSL(120, 100, 50)), + (terminal.RGB(0, 0, 255), terminal.HSL(240, 100, 50)), + (terminal.RGB(255, 255, 0), terminal.HSL(60, 100, 50)), + (terminal.RGB(0, 255, 255), terminal.HSL(180, 100, 50)), + (terminal.RGB(255, 0, 255), terminal.HSL(300, 100, 50)), + (terminal.RGB(128, 128, 128), terminal.HSL(0, 0, 50)), + (terminal.RGB(128, 0, 0), terminal.HSL(0, 100, 25)), + (terminal.RGB(128, 128, 0), terminal.HSL(60, 100, 25)), + (terminal.RGB(0, 128, 0), terminal.HSL(120, 100, 25)), + (terminal.RGB(128, 0, 128), terminal.HSL(300, 100, 25)), + (terminal.RGB(0, 128, 128), terminal.HSL(180, 100, 25)), + (terminal.RGB(0, 0, 128), terminal.HSL(240, 100, 25)), + (terminal.RGB(192, 192, 192), terminal.HSL(0, 0, 75)), + ], +) +def test_rgb_to_hls(rgb, hls): + assert terminal.HSL.from_rgb(rgb) == hls + + +@pytest.mark.parametrize( + 'text, fg, bg, fg_none, bg_none, percentage, expected', + [ + ('test', None, None, None, None, None, 'test'), + ('test', None, None, None, None, 1, 'test'), + ( + 'test', + None, + None, + None, + colors.red, + None, + '\x1b[48;5;9mtest\x1b[49m', + ), + ( + 'test', + None, + colors.green, + None, + colors.red, + None, + '\x1b[48;5;9mtest\x1b[49m', + ), + ('test', None, colors.red, None, None, 1, '\x1b[48;5;9mtest\x1b[49m'), + ('test', None, colors.red, None, None, None, 'test'), + ( + 'test', + colors.green, + None, + colors.red, + None, + None, + '\x1b[38;5;9mtest\x1b[39m', + ), + ( + 'test', + colors.green, + colors.red, + None, + None, + 1, + '\x1b[48;5;9m\x1b[38;5;2mtest\x1b[39m\x1b[49m', + ), + ('test', colors.red, None, None, None, 1, '\x1b[38;5;9mtest\x1b[39m'), + ('test', colors.red, None, None, None, None, 'test'), + ('test', colors.red, colors.red, None, None, None, 'test'), + ( + 'test', + colors.red, + colors.yellow, + None, + None, + 1, + '\x1b[48;5;11m\x1b[38;5;9mtest\x1b[39m\x1b[49m', + ), + ( + 'test', + colors.red, + colors.yellow, + None, + None, + 1, + '\x1b[48;5;11m\x1b[38;5;9mtest\x1b[39m\x1b[49m', + ), + ], +) +def test_apply_colors(text, fg, bg, fg_none, bg_none, percentage, expected, + monkeypatch): + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + progressbar.env.ColorSupport.XTERM_256, + ) + assert ( + apply_colors( + text, + fg=fg, + bg=bg, + fg_none=fg_none, + bg_none=bg_none, + percentage=percentage, + ) + == expected + ) + + +def test_ansi_color(monkeypatch): + color = progressbar.terminal.Color( + colors.red.rgb, + colors.red.hls, + 'red-ansi', + None, + ) + + for color_support in { + env.ColorSupport.NONE, + env.ColorSupport.XTERM, + env.ColorSupport.XTERM_256, + env.ColorSupport.XTERM_TRUECOLOR, + }: + monkeypatch.setattr( + env, + 'COLOR_SUPPORT', + color_support, + ) + assert color.ansi is not None or color_support == env.ColorSupport.NONE + + +def test_sgr_call(): + assert progressbar.terminal.encircled('test') == '\x1b[52mtest\x1b[54m' diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py index 95252818..477aef30 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -1,5 +1,7 @@ import time + import progressbar +import pytest class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): @@ -7,8 +9,8 @@ class CrazyFileTransferSpeed(progressbar.FileTransferSpeed): def update(self, pbar): if 45 < pbar.percentage() < 80: - return 'Bigger Now ' + progressbar.FileTransferSpeed.update(self, - pbar) + value = progressbar.FileTransferSpeed.update(self, pbar) + return f'Bigger Now {value}' else: return progressbar.FileTransferSpeed.update(self, pbar) @@ -36,9 +38,13 @@ def test_crazy_file_transfer_speed_widget(): def test_variable_widget_widget(): widgets = [ - ' [', progressbar.Timer(), '] ', + ' [', + progressbar.Timer(), + '] ', progressbar.Bar(), - ' (', progressbar.ETA(), ') ', + ' (', + progressbar.ETA(), + ') ', progressbar.Variable('loss'), progressbar.Variable('text'), progressbar.Variable('error', precision=None), @@ -46,13 +52,16 @@ def test_variable_widget_widget(): progressbar.Variable('predefined'), ] - p = progressbar.ProgressBar(widgets=widgets, max_value=1000, - variables=dict(predefined='predefined')) + p = progressbar.ProgressBar( + widgets=widgets, + max_value=1000, + variables=dict(predefined='predefined'), + ) p.start() print('time', time, time.sleep) for i in range(0, 200, 5): time.sleep(0.1) - p.update(i + 1, loss=.5, text='spam', error=1) + p.update(i + 1, loss=0.5, text='spam', error=1) i += 1 p.update(i, text=None) @@ -60,6 +69,8 @@ def test_variable_widget_widget(): p.update(i, text=False) i += 1 p.update(i, text=True, error='a') + with pytest.raises(TypeError): + p.update(i, non_existing_variable='error!') p.finish() @@ -72,11 +83,12 @@ def test_format_custom_text_widget(): ), ) - bar = progressbar.ProgressBar(widgets=[ - widget, - ]) + bar = progressbar.ProgressBar( + widgets=[ + widget, + ], + ) for i in bar(range(5)): widget.update_mapping(eggs=i * 2) assert widget.mapping['eggs'] == bar.widgets[0].mapping['eggs'] - diff --git a/tests/test_data.py b/tests/test_data.py index 039cffbb..ef6f5a3a 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,21 +1,24 @@ -import pytest import progressbar +import pytest -@pytest.mark.parametrize('value,expected', [ - (None, ' 0.0 B'), - (1, ' 1.0 B'), - (2 ** 10 - 1, '1023.0 B'), - (2 ** 10 + 0, ' 1.0 KiB'), - (2 ** 20, ' 1.0 MiB'), - (2 ** 30, ' 1.0 GiB'), - (2 ** 40, ' 1.0 TiB'), - (2 ** 50, ' 1.0 PiB'), - (2 ** 60, ' 1.0 EiB'), - (2 ** 70, ' 1.0 ZiB'), - (2 ** 80, ' 1.0 YiB'), - (2 ** 90, '1024.0 YiB'), -]) +@pytest.mark.parametrize( + 'value,expected', + [ + (None, ' 0.0 B'), + (1, ' 1.0 B'), + (2**10 - 1, '1023.0 B'), + (2**10 + 0, ' 1.0 KiB'), + (2**20, ' 1.0 MiB'), + (2**30, ' 1.0 GiB'), + (2**40, ' 1.0 TiB'), + (2**50, ' 1.0 PiB'), + (2**60, ' 1.0 EiB'), + (2**70, ' 1.0 ZiB'), + (2**80, ' 1.0 YiB'), + (2**90, '1024.0 YiB'), + ], +) def test_data_size(value, expected): widget = progressbar.DataSize() assert widget(None, dict(value=value)) == expected diff --git a/tests/test_dill_pickle.py b/tests/test_dill_pickle.py index bfa1da4b..8131a35f 100644 --- a/tests/test_dill_pickle.py +++ b/tests/test_dill_pickle.py @@ -1,5 +1,4 @@ -import dill - +import dill # type: ignore import progressbar diff --git a/tests/test_empty.py b/tests/test_empty.py index de6bf09a..ad0a430a 100644 --- a/tests/test_empty.py +++ b/tests/test_empty.py @@ -9,4 +9,3 @@ def test_empty_list(): def test_empty_iterator(): for x in progressbar.ProgressBar(max_value=0)(iter([])): print(x) - diff --git a/tests/test_end.py b/tests/test_end.py index 75d45723..e5af3f60 100644 --- a/tests/test_end.py +++ b/tests/test_end.py @@ -1,31 +1,34 @@ -import pytest import progressbar +import pytest @pytest.fixture(autouse=True) def large_interval(monkeypatch): # Remove the update limit for tests by default monkeypatch.setattr( - progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 0.1) + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + 0.1, + ) def test_end(): m = 24514315 p = progressbar.ProgressBar( widgets=[progressbar.Percentage(), progressbar.Bar()], - max_value=m + max_value=m, ) for x in range(0, m, 8192): p.update(x) data = p.data() - assert data['percentage'] < 100. + assert data['percentage'] < 100.0 p.finish() data = p.data() - assert data['percentage'] >= 100. + assert data['percentage'] >= 100.0 assert p.value == m @@ -37,15 +40,16 @@ def test_end_100(monkeypatch): max_value=103, ) - for x in range(0, 102): + for x in range(102): p.update(x) data = p.data() import pprint + pprint.pprint(data) - assert data['percentage'] < 100. + assert data['percentage'] < 100.0 p.finish() data = p.data() - assert data['percentage'] >= 100. + assert data['percentage'] >= 100.0 diff --git a/tests/test_failure.py b/tests/test_failure.py index 030ab292..cee84b78 100644 --- a/tests/test_failure.py +++ b/tests/test_failure.py @@ -1,6 +1,7 @@ import time -import pytest + import progressbar +import pytest def test_missing_format_values(): @@ -64,7 +65,7 @@ def test_one_max_value(): def test_changing_max_value(): '''Changing max_value? No problem''' p = progressbar.ProgressBar(max_value=10)(range(20), max_value=20) - for i in p: + for _i in p: time.sleep(1) @@ -122,3 +123,15 @@ def test_variable_not_str(): def test_variable_too_many_strs(): with pytest.raises(ValueError): progressbar.Variable('too long') + + +def test_negative_value(): + bar = progressbar.ProgressBar(max_value=10) + with pytest.raises(ValueError): + bar.update(value=-1) + + +def test_increment(): + bar = progressbar.ProgressBar(max_value=10) + bar.increment() + del bar diff --git a/tests/test_flush.py b/tests/test_flush.py index 69dc4e30..014b690a 100644 --- a/tests/test_flush.py +++ b/tests/test_flush.py @@ -1,10 +1,12 @@ import time + import progressbar def test_flush(): '''Left justify using the terminal width''' p = progressbar.ProgressBar(poll_interval=0.001) + p.print('hello') for i in range(10): print('pre-updates', p.updates) @@ -13,4 +15,3 @@ def test_flush(): if i > 5: time.sleep(0.1) print('post-updates', p.updates) - diff --git a/tests/test_iterators.py b/tests/test_iterators.py index b32c529e..ba48661f 100644 --- a/tests/test_iterators.py +++ b/tests/test_iterators.py @@ -1,19 +1,20 @@ import time -import pytest + import progressbar +import pytest def test_list(): '''Progressbar can guess max_value automatically.''' p = progressbar.ProgressBar() - for i in p(range(10)): + for _i in p(range(10)): time.sleep(0.001) def test_iterator_with_max_value(): '''Progressbar can't guess max_value.''' p = progressbar.ProgressBar(max_value=10) - for i in p((i for i in range(10))): + for _i in p(iter(range(10))): time.sleep(0.001) @@ -21,7 +22,7 @@ def test_iterator_without_max_value_error(): '''Progressbar can't guess max_value.''' p = progressbar.ProgressBar() - for i in p((i for i in range(10))): + for _i in p(iter(range(10))): time.sleep(0.001) assert p.max_value is progressbar.UnknownLength @@ -29,13 +30,15 @@ def test_iterator_without_max_value_error(): def test_iterator_without_max_value(): '''Progressbar can't guess max_value.''' - p = progressbar.ProgressBar(widgets=[ - progressbar.AnimatedMarker(), - progressbar.FormatLabel('%(value)d'), - progressbar.BouncingBar(), - progressbar.BouncingBar(marker=progressbar.RotatingMarker()), - ]) - for i in p((i for i in range(10))): + p = progressbar.ProgressBar( + widgets=[ + progressbar.AnimatedMarker(), + progressbar.FormatLabel('%(value)d'), + progressbar.BouncingBar(), + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + ) + for _i in p(iter(range(10))): time.sleep(0.001) @@ -43,7 +46,7 @@ def test_iterator_with_incorrect_max_value(): '''Progressbar can't guess max_value.''' p = progressbar.ProgressBar(max_value=10) with pytest.raises(ValueError): - for i in p((i for i in range(20))): + for _i in p(iter(range(20))): time.sleep(0.001) @@ -55,4 +58,3 @@ def test_adding_value(): p.increment(2) with pytest.raises(ValueError): p += 5 - diff --git a/tests/test_job_status.py b/tests/test_job_status.py new file mode 100644 index 00000000..f22484f5 --- /dev/null +++ b/tests/test_job_status.py @@ -0,0 +1,19 @@ +import time + +import progressbar +import pytest + + +@pytest.mark.parametrize('status', [ + True, + False, + None, +]) +def test_status(status): + with progressbar.ProgressBar( + widgets=[progressbar.widgets.JobStatusBar('status')], + ) as bar: + for _ in range(5): + bar.increment(status=status, force=True) + time.sleep(0.1) + diff --git a/tests/test_large_values.py b/tests/test_large_values.py index 9a7704f4..f251c32e 100644 --- a/tests/test_large_values.py +++ b/tests/test_large_values.py @@ -1,4 +1,5 @@ import time + import progressbar diff --git a/tests/test_monitor_progress.py b/tests/test_monitor_progress.py index 5dd6f5ee..71052546 100644 --- a/tests/test_monitor_progress.py +++ b/tests/test_monitor_progress.py @@ -1,10 +1,10 @@ import os import pprint + import progressbar pytest_plugins = 'pytester' - SCRIPT = ''' import sys sys.path.append({progressbar_path!r}) @@ -23,9 +23,19 @@ ''' -def _create_script(widgets=None, items=list(range(9)), - loop_code='fake_time.tick(1)', term_width=60, - **kwargs): +def _non_empty_lines(lines): + return [line for line in lines if line.strip()] + + +def _create_script( + widgets=None, + items=None, + loop_code='fake_time.tick(1)', + term_width=60, + **kwargs, +): + if items is None: + items = list(range(9)) kwargs['term_width'] = term_width # Reindent the loop code @@ -40,8 +50,9 @@ def _create_script(widgets=None, items=list(range(9)), widgets=widgets, kwargs=kwargs, loop_code=indent.join(loop_code), - progressbar_path=os.path.dirname(os.path.dirname( - progressbar.__file__)), + progressbar_path=os.path.dirname( + os.path.dirname(progressbar.__file__), + ), ) print('# Script:') print('#' * 78) @@ -52,177 +63,216 @@ def _create_script(widgets=None, items=list(range(9)), def test_list_example(testdir): - ''' Run the simple example code in a python subprocess and then compare its - stderr to what we expect to see from it. We run it in a subprocess to - best capture its stderr. We expect to see match_lines in order in the - output. This test is just a sanity check to ensure that the progress - bar progresses from 1 to 10, it does not make sure that the ''' - - result = testdir.runpython(testdir.makepyfile(_create_script( - term_width=65, - ))) - result.stderr.lines = [l.rstrip() for l in result.stderr.lines - if l.strip()] + '''Run the simple example code in a python subprocess and then compare its + stderr to what we expect to see from it. We run it in a subprocess to + best capture its stderr. We expect to see match_lines in order in the + output. This test is just a sanity check to ensure that the progress + bar progresses from 1 to 10, it does not make sure that the''' + + result = testdir.runpython( + testdir.makepyfile( + _create_script( + term_width=65, + ), + ), + ) + result.stderr.lines = [ + line.rstrip() for line in _non_empty_lines(result.stderr.lines) + ] pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines([ - ' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--', - ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08', - ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07', - ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06', - ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05', - ' 55% (5 of 9) |###### | Elapsed Time: ?:00:05 ETA: ?:00:04', - ' 66% (6 of 9) |######## | Elapsed Time: ?:00:06 ETA: ?:00:03', - ' 77% (7 of 9) |######### | Elapsed Time: ?:00:07 ETA: ?:00:02', - ' 88% (8 of 9) |########## | Elapsed Time: ?:00:08 ETA: ?:00:01', - '100% (9 of 9) |############| Elapsed Time: ?:00:09 Time: ?:00:09', - ]) + result.stderr.fnmatch_lines( + [ + ' 0% (0 of 9) | | Elapsed Time: ?:00:00 ETA: --:--:--', + ' 11% (1 of 9) |# | Elapsed Time: ?:00:01 ETA: ?:00:08', + ' 22% (2 of 9) |## | Elapsed Time: ?:00:02 ETA: ?:00:07', + ' 33% (3 of 9) |#### | Elapsed Time: ?:00:03 ETA: ?:00:06', + ' 44% (4 of 9) |##### | Elapsed Time: ?:00:04 ETA: ?:00:05', + ' 55% (5 of 9) |###### | Elapsed Time: ?:00:05 ETA: ?:00:04', + ' 66% (6 of 9) |######## | Elapsed Time: ?:00:06 ETA: ?:00:03', + ' 77% (7 of 9) |######### | Elapsed Time: ?:00:07 ETA: ?:00:02', + ' 88% (8 of 9) |########## | Elapsed Time: ?:00:08 ETA: ?:00:01', + '100% (9 of 9) |############| Elapsed Time: ?:00:09 Time: ?:00:09', + ], + ) def test_generator_example(testdir): - ''' Run the simple example code in a python subprocess and then compare its - stderr to what we expect to see from it. We run it in a subprocess to - best capture its stderr. We expect to see match_lines in order in the - output. This test is just a sanity check to ensure that the progress - bar progresses from 1 to 10, it does not make sure that the ''' - result = testdir.runpython(testdir.makepyfile(_create_script( - items='iter(range(9))', - ))) - result.stderr.lines = [l for l in result.stderr.lines if l.strip()] + '''Run the simple example code in a python subprocess and then compare its + stderr to what we expect to see from it. We run it in a subprocess to + best capture its stderr. We expect to see match_lines in order in the + output. This test is just a sanity check to ensure that the progress + bar progresses from 1 to 10, it does not make sure that the''' + result = testdir.runpython( + testdir.makepyfile( + _create_script( + items='iter(range(9))', + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) pprint.pprint(result.stderr.lines, width=70) - lines = [] - for i in range(9): - lines.append( - r'[/\\|\-]\s+\|\s*#\s*\| %(i)d Elapsed Time: \d:00:%(i)02d' % - dict(i=i)) - + lines = [ + r'[/\\|\-]\s+\|\s*#\s*\| %(i)d Elapsed Time: \d:00:%(i)02d' % dict(i=i) + for i in range(9) + ] result.stderr.re_match_lines(lines) def test_rapid_updates(testdir): - ''' Run some example code that updates 10 times, then sleeps .1 seconds, - this is meant to test that the progressbar progresses normally with - this sample code, since there were issues with it in the past ''' - - result = testdir.runpython(testdir.makepyfile(_create_script( - term_width=60, - items=list(range(10)), - loop_code=''' + '''Run some example code that updates 10 times, then sleeps .1 seconds, + this is meant to test that the progressbar progresses normally with + this sample code, since there were issues with it in the past''' + + result = testdir.runpython( + testdir.makepyfile( + _create_script( + term_width=60, + items=list(range(10)), + loop_code=''' if i < 5: fake_time.tick(1) else: fake_time.tick(2) - ''' - ))) - result.stderr.lines = [l for l in result.stderr.lines if l.strip()] + ''', + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines([ - ' 0% (0 of 10) | | Elapsed Time: ?:00:00 ETA: --:--:--', - ' 10% (1 of 10) | | Elapsed Time: ?:00:01 ETA: ?:00:09', - ' 20% (2 of 10) |# | Elapsed Time: ?:00:02 ETA: ?:00:08', - ' 30% (3 of 10) |# | Elapsed Time: ?:00:03 ETA: ?:00:07', - ' 40% (4 of 10) |## | Elapsed Time: ?:00:04 ETA: ?:00:06', - ' 50% (5 of 10) |### | Elapsed Time: ?:00:05 ETA: ?:00:05', - ' 60% (6 of 10) |### | Elapsed Time: ?:00:07 ETA: ?:00:06', - ' 70% (7 of 10) |#### | Elapsed Time: ?:00:09 ETA: ?:00:06', - ' 80% (8 of 10) |#### | Elapsed Time: ?:00:11 ETA: ?:00:04', - ' 90% (9 of 10) |##### | Elapsed Time: ?:00:13 ETA: ?:00:02', - '100% (10 of 10) |#####| Elapsed Time: ?:00:15 Time: ?:00:15' - ]) + result.stderr.fnmatch_lines( + [ + ' 0% (0 of 10) | | Elapsed Time: ?:00:00 ETA: --:--:--', + ' 10% (1 of 10) | | Elapsed Time: ?:00:01 ETA: ?:00:09', + ' 20% (2 of 10) |# | Elapsed Time: ?:00:02 ETA: ?:00:08', + ' 30% (3 of 10) |# | Elapsed Time: ?:00:03 ETA: ?:00:07', + ' 40% (4 of 10) |## | Elapsed Time: ?:00:04 ETA: ?:00:06', + ' 50% (5 of 10) |### | Elapsed Time: ?:00:05 ETA: ?:00:05', + ' 60% (6 of 10) |### | Elapsed Time: ?:00:07 ETA: ?:00:06', + ' 70% (7 of 10) |#### | Elapsed Time: ?:00:09 ETA: ?:00:06', + ' 80% (8 of 10) |#### | Elapsed Time: ?:00:11 ETA: ?:00:04', + ' 90% (9 of 10) |##### | Elapsed Time: ?:00:13 ETA: ?:00:02', + '100% (10 of 10) |#####| Elapsed Time: ?:00:15 Time: ?:00:15', + ], + ) def test_non_timed(testdir): - result = testdir.runpython(testdir.makepyfile(_create_script( - widgets='[progressbar.Percentage(), progressbar.Bar()]', - items=list(range(5)), - ))) - result.stderr.lines = [l for l in result.stderr.lines if l.strip()] + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + items=list(range(5)), + ), + ), + ) + result.stderr.lines = _non_empty_lines(result.stderr.lines) pprint.pprint(result.stderr.lines, width=70) - result.stderr.fnmatch_lines([ - ' 0%| |', - ' 20%|########## |', - ' 40%|##################### |', - ' 60%|################################ |', - ' 80%|########################################### |', - '100%|######################################################|', - ]) + result.stderr.fnmatch_lines( + [ + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + ], + ) def test_line_breaks(testdir): - result = testdir.runpython(testdir.makepyfile(_create_script( - widgets='[progressbar.Percentage(), progressbar.Bar()]', - line_breaks=True, - items=list(range(5)), - ))) + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + line_breaks=True, + items=list(range(5)), + ), + ), + ) pprint.pprint(result.stderr.str(), width=70) - assert result.stderr.str() == u'\n'.join(( - u' 0%| |', - u' 20%|########## |', - u' 40%|##################### |', - u' 60%|################################ |', - u' 80%|########################################### |', - u'100%|######################################################|', - u'100%|######################################################|', - )) + assert result.stderr.str() == '\n'.join( + ( + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + '100%|######################################################|', + ), + ) def test_no_line_breaks(testdir): - result = testdir.runpython(testdir.makepyfile(_create_script( - widgets='[progressbar.Percentage(), progressbar.Bar()]', - line_breaks=False, - items=list(range(5)), - ))) + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.Percentage(), progressbar.Bar()]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) pprint.pprint(result.stderr.lines, width=70) assert result.stderr.lines == [ - u'', - u' 0%| |', - u' 20%|########## |', - u' 40%|##################### |', - u' 60%|################################ |', - u' 80%|########################################### |', - u'100%|######################################################|', - u'', - u'100%|######################################################|' + '', + ' 0%| |', + ' 20%|########## |', + ' 40%|##################### |', + ' 60%|################################ |', + ' 80%|########################################### |', + '100%|######################################################|', + '', + '100%|######################################################|', ] def test_percentage_label_bar(testdir): - result = testdir.runpython(testdir.makepyfile(_create_script( - widgets='[progressbar.PercentageLabelBar()]', - line_breaks=False, - items=list(range(5)), - ))) + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.PercentageLabelBar()]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) pprint.pprint(result.stderr.lines, width=70) assert result.stderr.lines == [ - u'', - u'| 0% |', - u'|########### 20% |', - u'|####################### 40% |', - u'|###########################60%#### |', - u'|###########################80%################ |', - u'|###########################100%###########################|', - u'', - u'|###########################100%###########################|' + '', + '| 0% |', + '|########### 20% |', + '|####################### 40% |', + '|###########################60%#### |', + '|###########################80%################ |', + '|###########################100%###########################|', + '', + '|###########################100%###########################|', ] def test_granular_bar(testdir): - result = testdir.runpython(testdir.makepyfile(_create_script( - widgets='[progressbar.GranularBar(markers=" .oO")]', - line_breaks=False, - items=list(range(5)), - ))) + result = testdir.runpython( + testdir.makepyfile( + _create_script( + widgets='[progressbar.GranularBar(markers=" .oO")]', + line_breaks=False, + items=list(range(5)), + ), + ), + ) pprint.pprint(result.stderr.lines, width=70) assert result.stderr.lines == [ - u'', - u'| |', - u'|OOOOOOOOOOO. |', - u'|OOOOOOOOOOOOOOOOOOOOOOO |', - u'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOo |', - u'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO. |', - u'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|', - u'', - u'|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|' + '', + '| |', + '|OOOOOOOOOOO. |', + '|OOOOOOOOOOOOOOOOOOOOOOO |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOo |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO. |', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|', + '', + '|OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO|', ] @@ -232,12 +282,14 @@ def test_colors(testdir): widgets=['\033[92mgreen\033[0m'], ) - result = testdir.runpython(testdir.makepyfile(_create_script( - enable_colors=True, **kwargs))) + result = testdir.runpython( + testdir.makepyfile(_create_script(enable_colors=True, **kwargs)), + ) pprint.pprint(result.stderr.lines, width=70) - assert result.stderr.lines == [u'\x1b[92mgreen\x1b[0m'] * 3 + assert result.stderr.lines == ['\x1b[92mgreen\x1b[0m'] * 3 - result = testdir.runpython(testdir.makepyfile(_create_script( - enable_colors=False, **kwargs))) + result = testdir.runpython( + testdir.makepyfile(_create_script(enable_colors=False, **kwargs)), + ) pprint.pprint(result.stderr.lines, width=70) - assert result.stderr.lines == [u'green'] * 3 + assert result.stderr.lines == ['green'] * 3 diff --git a/tests/test_multibar.py b/tests/test_multibar.py index fe1c569f..561e44f0 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -1,5 +1,13 @@ -import pytest +import random +import threading +import time + import progressbar +import pytest + +N = 10 +BARS = 3 +SLEEP = 0.002 def test_multi_progress_bar_out_of_range(): @@ -15,6 +23,227 @@ def test_multi_progress_bar_out_of_range(): bar.update(multivalues=[-1]) -def test_multi_progress_bar_fill_left(): - import examples - return examples.multi_progress_bar_example(False) +def test_multibar(): + multibar = progressbar.MultiBar( + sort_keyfunc=lambda bar: bar.label, + remove_finished=0.005, + ) + multibar.show_initial = False + multibar.render(force=True) + multibar.show_initial = True + multibar.render(force=True) + multibar.start() + + multibar.append_label = False + multibar.prepend_label = True + + # Test handling of progressbars that don't call the super constructors + bar = progressbar.ProgressBar(max_value=N) + bar.index = -1 + multibar['x'] = bar + bar.start() + # Test twice for other code paths + multibar['x'] = bar + multibar._label_bar(bar) + multibar._label_bar(bar) + bar.finish() + del multibar['x'] + + multibar.prepend_label = False + multibar.append_label = True + + append_bar = progressbar.ProgressBar(max_value=N) + append_bar.start() + multibar._label_bar(append_bar) + multibar['append'] = append_bar + multibar.render(force=True) + + def do_something(bar): + for j in bar(range(N)): + time.sleep(0.01) + bar.update(j) + + for i in range(BARS): + thread = threading.Thread( + target=do_something, + args=(multibar[f'bar {i}'],), + ) + thread.start() + + for bar in list(multibar.values()): + for j in range(N): + bar.update(j) + time.sleep(SLEEP) + + multibar.render(force=True) + + multibar.remove_finished = False + multibar.show_finished = False + append_bar.finish() + multibar.render(force=True) + + multibar.join(0.1) + multibar.stop(0.1) + + +@pytest.mark.parametrize( + 'sort_key', + [ + None, + 'index', + 'label', + 'value', + 'percentage', + progressbar.SortKey.CREATED, + progressbar.SortKey.LABEL, + progressbar.SortKey.VALUE, + progressbar.SortKey.PERCENTAGE, + ], +) +def test_multibar_sorting(sort_key): + with progressbar.MultiBar() as multibar: + for i in range(BARS): + label = f'bar {i}' + multibar[label] = progressbar.ProgressBar(max_value=N) + + for bar in multibar.values(): + for _j in bar(range(N)): + assert bar.started() + time.sleep(SLEEP) + + for bar in multibar.values(): + assert bar.finished() + + +def test_offset_bar(): + with progressbar.ProgressBar(line_offset=2) as bar: + for i in range(N): + bar.update(i) + + +def test_multibar_show_finished(): + multibar = progressbar.MultiBar(show_finished=True) + multibar['bar'] = progressbar.ProgressBar(max_value=N) + multibar.render(force=True) + with progressbar.MultiBar(show_finished=False) as multibar: + multibar.finished_format = 'finished: {label}' + + for i in range(3): + multibar[f'bar {i}'] = progressbar.ProgressBar(max_value=N) + + for bar in multibar.values(): + for i in range(N): + bar.update(i) + time.sleep(SLEEP) + + multibar.render(force=True) + + +def test_multibar_show_initial(): + multibar = progressbar.MultiBar(show_initial=False) + multibar['bar'] = progressbar.ProgressBar(max_value=N) + multibar.render(force=True) + + +def test_multibar_empty_key(): + multibar = progressbar.MultiBar() + multibar[''] = progressbar.ProgressBar(max_value=N) + + for name in multibar: + assert name == '' + bar = multibar[name] + bar.update(1) + + multibar.render(force=True) + + +def test_multibar_print(): + + bars = 5 + n = 10 + + + def print_sometimes(bar, probability): + for i in bar(range(n)): + # Sleep up to 0.1 seconds + time.sleep(random.random() * 0.1) + + # print messages at random intervals to show how extra output works + if random.random() < probability: + bar.print('random message for bar', bar, i) + + with progressbar.MultiBar() as multibar: + for i in range(bars): + # Get a progressbar + bar = multibar[f'Thread label here {i}'] + bar.max_error = False + # Create a thread and pass the progressbar + # Print never, sometimes and always + threading.Thread(target=print_sometimes, args=(bar, 0)).start() + threading.Thread(target=print_sometimes, args=(bar, 0.5)).start() + threading.Thread(target=print_sometimes, args=(bar, 1)).start() + + + for i in range(5): + multibar.print(f'{i}', flush=False) + + multibar.update(force=True, flush=False) + multibar.update(force=True, flush=True) + +def test_multibar_no_format(): + with progressbar.MultiBar( + initial_format=None, finished_format=None) as multibar: + bar = multibar['a'] + + for i in bar(range(5)): + bar.print(i) + + +def test_multibar_finished(): + multibar = progressbar.MultiBar(initial_format=None, finished_format=None) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + bar2 = multibar['bar2'] + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + + for i in range(6): + bar.update(i) + bar2.update(i) + + multibar.render(force=True) + + + +def test_multibar_finished_format(): + multibar = progressbar.MultiBar( + finished_format='Finished {label}', show_finished=True) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + bar2 = multibar['bar2'] + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + bar.start() + bar2.start() + multibar.render(force=True) + multibar.print('Hi') + multibar.render(force=True, flush=False) + + for i in range(6): + bar.update(i) + bar2.update(i) + + multibar.render(force=True) + + +def test_multibar_threads(): + multibar = progressbar.MultiBar(finished_format=None, show_finished=True) + bar = multibar['bar'] = progressbar.ProgressBar(max_value=5) + multibar.start() + time.sleep(0.1) + bar.update(3) + time.sleep(0.1) + multibar.join() + bar.finish() + multibar.join() + multibar.render(force=True) diff --git a/tests/test_progressbar.py b/tests/test_progressbar.py index 32083eb0..d418d4c4 100644 --- a/tests/test_progressbar.py +++ b/tests/test_progressbar.py @@ -1,31 +1,34 @@ +import contextlib +import os import time -import pytest + +import original_examples # type: ignore import progressbar -import original_examples +import pytest # Import hack to allow for parallel Tox try: import examples except ImportError: import sys - sys.path.append('..') + + _project_dir = os.path.dirname(os.path.dirname(__file__)) + sys.path.append(_project_dir) import examples - sys.path.remove('..') + + sys.path.remove(_project_dir) def test_examples(monkeypatch): for example in examples.examples: - try: + with contextlib.suppress(ValueError): example() - except ValueError: - pass @pytest.mark.filterwarnings('ignore:.*maxval.*:DeprecationWarning') @pytest.mark.parametrize('example', original_examples.examples) def test_original_examples(example, monkeypatch): - monkeypatch.setattr(progressbar.ProgressBar, - '_MINIMUM_UPDATE_INTERVAL', 1) + monkeypatch.setattr(progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', 1) monkeypatch.setattr(time, 'sleep', lambda t: None) example() @@ -39,8 +42,6 @@ def test_examples_nullbar(monkeypatch, example): def test_reuse(): - import progressbar - bar = progressbar.ProgressBar() bar.start() for i in range(10): @@ -59,10 +60,11 @@ def test_reuse(): def test_dirty(): - import progressbar - bar = progressbar.ProgressBar() bar.start() + assert bar.started() for i in range(10): bar.update(i) bar.finish(dirty=True) + assert bar.finished() + assert bar.started() diff --git a/tests/test_samples.py b/tests/test_samples.py index 4e553c29..5ab388bd 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -1,8 +1,9 @@ import time -from datetime import timedelta -from datetime import datetime +from datetime import datetime, timedelta + import progressbar from progressbar import widgets +from python_utils.containers import SliceableDeque def test_numeric_samples(): @@ -36,7 +37,9 @@ def test_numeric_samples(): bar.last_update_time = start + timedelta(seconds=bar.value) assert samples_widget(bar, None, True) == (timedelta(0, 16), 16) - assert samples_widget(bar, None)[1] == [4, 5, 8, 10, 20] + assert samples_widget(bar, None)[1] == SliceableDeque( + [4, 5, 8, 10, 20], + ) def test_timedelta_samples(): diff --git a/tests/test_speed.py b/tests/test_speed.py index d7a338b3..0496daf5 100644 --- a/tests/test_speed.py +++ b/tests/test_speed.py @@ -1,27 +1,35 @@ -import pytest import progressbar +import pytest -@pytest.mark.parametrize('total_seconds_elapsed,value,expected', [ - (1, 0, ' 0.0 s/B'), - (1, 0.01, '100.0 s/B'), - (1, 0.1, ' 0.1 B/s'), - (1, 1, ' 1.0 B/s'), - (1, 2 ** 10 - 1, '1023.0 B/s'), - (1, 2 ** 10 + 0, ' 1.0 KiB/s'), - (1, 2 ** 20, ' 1.0 MiB/s'), - (1, 2 ** 30, ' 1.0 GiB/s'), - (1, 2 ** 40, ' 1.0 TiB/s'), - (1, 2 ** 50, ' 1.0 PiB/s'), - (1, 2 ** 60, ' 1.0 EiB/s'), - (1, 2 ** 70, ' 1.0 ZiB/s'), - (1, 2 ** 80, ' 1.0 YiB/s'), - (1, 2 ** 90, '1024.0 YiB/s'), -]) +@pytest.mark.parametrize( + 'total_seconds_elapsed,value,expected', + [ + (1, 0, ' 0.0 s/B'), + (1, 0.01, '100.0 s/B'), + (1, 0.1, ' 0.1 B/s'), + (1, 1, ' 1.0 B/s'), + (1, 2**10 - 1, '1023.0 B/s'), + (1, 2**10 + 0, ' 1.0 KiB/s'), + (1, 2**20, ' 1.0 MiB/s'), + (1, 2**30, ' 1.0 GiB/s'), + (1, 2**40, ' 1.0 TiB/s'), + (1, 2**50, ' 1.0 PiB/s'), + (1, 2**60, ' 1.0 EiB/s'), + (1, 2**70, ' 1.0 ZiB/s'), + (1, 2**80, ' 1.0 YiB/s'), + (1, 2**90, '1024.0 YiB/s'), + ], +) def test_file_transfer_speed(total_seconds_elapsed, value, expected): widget = progressbar.FileTransferSpeed() - assert widget(None, dict( - total_seconds_elapsed=total_seconds_elapsed, - value=value, - )) == expected - + assert ( + widget( + None, + dict( + total_seconds_elapsed=total_seconds_elapsed, + value=value, + ), + ) + == expected + ) diff --git a/tests/test_stream.py b/tests/test_stream.py index 6dcfcf7c..c92edf7d 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,12 +1,14 @@ import io import sys -import pytest + import progressbar +import pytest +from progressbar import terminal def test_nowrap(): # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) stdout = sys.stdout @@ -23,13 +25,13 @@ def test_nowrap(): assert stderr == sys.stderr # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) def test_wrap(): # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) stdout = sys.stdout @@ -50,7 +52,7 @@ def test_wrap(): assert stderr == sys.stderr # Make sure we definitely unwrap - for i in range(5): + for _i in range(5): progressbar.streams.unwrap(stderr=True, stdout=True) @@ -58,7 +60,7 @@ def test_excepthook(): progressbar.streams.wrap(stderr=True, stdout=True) try: - raise RuntimeError() + raise RuntimeError() # noqa: TRY301 except RuntimeError: progressbar.streams.excepthook(*sys.exc_info()) @@ -100,3 +102,46 @@ def test_fd_as_standard_streams(stream): with progressbar.ProgressBar(fd=stream) as pb: for i in range(101): pb.update(i) + + +def test_line_offset_stream_wrapper(): + stream = terminal.LineOffsetStreamWrapper(5, io.StringIO()) + stream.write('Hello World!') + + +def test_last_line_stream_methods(): + stream = terminal.LastLineStream(io.StringIO()) + + # Test write method + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test flush method + stream.flush() + assert stream.line == 'Hello World!' + assert stream.readline() == 'Hello World!' + assert stream.readline(5) == 'Hello' + + # Test truncate method + stream.truncate(5) + assert stream.line == 'Hello' + stream.truncate() + assert stream.line == '' + + # Test seekable/readable + assert not stream.seekable() + assert stream.readable() + + stream.writelines(['a', 'b', 'c']) + assert stream.read() == 'c' + + assert list(stream) == ['c'] + + with stream: + stream.write('Hello World!') + assert stream.read() == 'Hello World!' + assert stream.read(5) == 'Hello' + + # Test close method + stream.close() diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 997bb0d6..ad61b7fa 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -1,15 +1,20 @@ +import signal import sys import time -import signal -import progressbar from datetime import timedelta +import progressbar +from progressbar import terminal + def test_left_justify(): '''Left justify using the terminal width''' p = progressbar.ProgressBar( widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, term_width=20, left_justify=True) + max_value=100, + term_width=20, + left_justify=True, + ) assert p.term_width is not None for i in range(100): @@ -20,7 +25,10 @@ def test_right_justify(): '''Right justify using the terminal width''' p = progressbar.ProgressBar( widgets=[progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, term_width=20, left_justify=False) + max_value=100, + term_width=20, + left_justify=False, + ) assert p.term_width is not None for i in range(100): @@ -38,12 +46,17 @@ def fake_signal(signal, func): try: import fcntl + monkeypatch.setattr(fcntl, 'ioctl', ioctl) monkeypatch.setattr(signal, 'signal', fake_signal) p = progressbar.ProgressBar( widgets=[ - progressbar.BouncingBar(marker=progressbar.RotatingMarker())], - max_value=100, left_justify=True, term_width=None) + progressbar.BouncingBar(marker=progressbar.RotatingMarker()), + ], + max_value=100, + left_justify=True, + term_width=None, + ) assert p.term_width is not None for i in range(100): @@ -56,7 +69,9 @@ def test_fill_right(): '''Right justify using the terminal width''' p = progressbar.ProgressBar( widgets=[progressbar.BouncingBar(fill_left=False)], - max_value=100, term_width=20) + max_value=100, + term_width=20, + ) assert p.term_width is not None for i in range(100): @@ -67,7 +82,9 @@ def test_fill_left(): '''Right justify using the terminal width''' p = progressbar.ProgressBar( widgets=[progressbar.BouncingBar(fill_left=True)], - max_value=100, term_width=20) + max_value=100, + term_width=20, + ) assert p.term_width is not None for i in range(100): @@ -81,7 +98,8 @@ def test_no_fill(monkeypatch): p = progressbar.ProgressBar( widgets=[bar], max_value=progressbar.UnknownLength, - term_width=20) + term_width=20, + ) assert p.term_width is not None for i in range(30): @@ -91,8 +109,11 @@ def test_no_fill(monkeypatch): def test_stdout_redirection(): - p = progressbar.ProgressBar(fd=sys.stdout, max_value=10, - redirect_stdout=True) + p = progressbar.ProgressBar( + fd=sys.stdout, + max_value=10, + redirect_stdout=True, + ) for i in range(10): print('', file=sys.stdout) @@ -118,8 +139,11 @@ def test_stderr_redirection(): def test_stdout_stderr_redirection(): - p = progressbar.ProgressBar(max_value=10, redirect_stdout=True, - redirect_stderr=True) + p = progressbar.ProgressBar( + max_value=10, + redirect_stdout=True, + redirect_stderr=True, + ) p.start() for i in range(10): @@ -140,6 +164,7 @@ def fake_signal(signal, func): try: import fcntl + monkeypatch.setattr(fcntl, 'ioctl', ioctl) monkeypatch.setattr(signal, 'signal', fake_signal) @@ -154,3 +179,10 @@ def fake_signal(signal, func): except ImportError: pass # Skip on Windows + +def test_base(): + assert str(terminal.CUP) + assert str(terminal.CLEAR_SCREEN_ALL_AND_HISTORY) + + terminal.clear_line(0) + terminal.clear_line(1) diff --git a/tests/test_timed.py b/tests/test_timed.py index 6753f537..4d71ec64 100644 --- a/tests/test_timed.py +++ b/tests/test_timed.py @@ -1,5 +1,6 @@ -import time import datetime +import time + import progressbar @@ -8,8 +9,11 @@ def test_timer(): widgets = [ progressbar.Timer(), ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update() @@ -25,8 +29,12 @@ def test_eta(): widgets = [ progressbar.ETA(), ] - p = progressbar.ProgressBar(min_value=0, max_value=2, widgets=widgets, - poll_interval=0.0001) + p = progressbar.ProgressBar( + min_value=0, + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() time.sleep(0.001) @@ -57,7 +65,7 @@ def test_adaptive_eta(): ) p.start() - for i in range(20): + for _i in range(20): p.update(1) time.sleep(0.001) p.finish() @@ -68,8 +76,11 @@ def test_adaptive_transfer_speed(): widgets = [ progressbar.AdaptiveTransferSpeed(), ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update(1) @@ -100,8 +111,11 @@ def calculate_eta(self, value, elapsed): return 0, 0 monkeypatch.setattr(progressbar.FileTransferSpeed, '_speed', calculate_eta) - monkeypatch.setattr(progressbar.AdaptiveTransferSpeed, '_speed', - calculate_eta) + monkeypatch.setattr( + progressbar.AdaptiveTransferSpeed, + '_speed', + calculate_eta, + ) for widget in widgets: widget.INTERVAL = interval @@ -144,8 +158,11 @@ def test_non_changing_eta(): progressbar.ETA(), progressbar.AdaptiveTransferSpeed(), ] - p = progressbar.ProgressBar(max_value=2, widgets=widgets, - poll_interval=0.0001) + p = progressbar.ProgressBar( + max_value=2, + widgets=widgets, + poll_interval=0.0001, + ) p.start() p.update(1) @@ -155,16 +172,16 @@ def test_non_changing_eta(): def test_eta_not_available(): - """ - When ETA is not available (data coming from a generator), - ETAs should not raise exceptions. - """ + ''' + When ETA is not available (data coming from a generator), + ETAs should not raise exceptions. + ''' + def gen(): - for x in range(200): - yield x + yield from range(200) widgets = [progressbar.AdaptiveETA(), progressbar.ETA()] bar = progressbar.ProgressBar(widgets=widgets) - for i in bar(gen()): + for _i in bar(gen()): pass diff --git a/tests/test_timer.py b/tests/test_timer.py index bc51c64a..b6cab792 100644 --- a/tests/test_timer.py +++ b/tests/test_timer.py @@ -1,32 +1,44 @@ -import pytest from datetime import timedelta import progressbar +import pytest -@pytest.mark.parametrize('poll_interval,expected', [ - (1, 1), - (timedelta(seconds=1), 1), - (0.001, 0.001), - (timedelta(microseconds=1000), 0.001), -]) -@pytest.mark.parametrize('parameter', [ - 'poll_interval', - 'min_poll_interval', -]) +@pytest.mark.parametrize( + 'poll_interval,expected', + [ + (1, 1), + (timedelta(seconds=1), 1), + (0.001, 0.001), + (timedelta(microseconds=1000), 0.001), + ], +) +@pytest.mark.parametrize( + 'parameter', + [ + 'poll_interval', + 'min_poll_interval', + ], +) def test_poll_interval(parameter, poll_interval, expected): # Test int, float and timedelta intervals bar = progressbar.ProgressBar(**{parameter: poll_interval}) assert getattr(bar, parameter) == expected -@pytest.mark.parametrize('interval', [ - 1, - timedelta(seconds=1), -]) +@pytest.mark.parametrize( + 'interval', + [ + 1, + timedelta(seconds=1), + ], +) def test_intervals(monkeypatch, interval): - monkeypatch.setattr(progressbar.ProgressBar, '_MINIMUM_UPDATE_INTERVAL', - interval) + monkeypatch.setattr( + progressbar.ProgressBar, + '_MINIMUM_UPDATE_INTERVAL', + interval, + ) bar = progressbar.ProgressBar(max_value=100) # Initially there should be no last_update_time @@ -45,4 +57,3 @@ def test_intervals(monkeypatch, interval): bar._last_update_time -= 2 bar.update(3) assert bar.last_update_time != last_update_time - diff --git a/tests/test_unicode.py b/tests/test_unicode.py index 3b8e4aec..98c740f3 100644 --- a/tests/test_unicode.py +++ b/tests/test_unicode.py @@ -1,16 +1,18 @@ -# -*- coding: utf-8 -*- - import time -import pytest + import progressbar +import pytest from python_utils import converters -@pytest.mark.parametrize('name,markers', [ - ('line arrows', u'←↖↑↗→↘↓↙'), - ('block arrows', u'◢◣◤◥'), - ('wheels', u'◐◓◑◒'), -]) +@pytest.mark.parametrize( + 'name,markers', + [ + ('line arrows', '←↖↑↗→↘↓↙'), + ('block arrows', '◢◣◤◥'), + ('wheels', '◐◓◑◒'), + ], +) @pytest.mark.parametrize('as_unicode', [True, False]) def test_markers(name, markers, as_unicode): if as_unicode: @@ -19,11 +21,10 @@ def test_markers(name, markers, as_unicode): markers = converters.to_str(markers) widgets = [ - '%s: ' % name.capitalize(), + f'{name.capitalize()}: ', progressbar.AnimatedMarker(markers=markers), ] bar = progressbar.ProgressBar(widgets=widgets) bar._MINIMUM_UPDATE_INTERVAL = 1e-12 - for i in bar((i for i in range(24))): + for _i in bar(iter(range(24))): time.sleep(0.001) - diff --git a/tests/test_unknown_length.py b/tests/test_unknown_length.py index fe08e209..77e3f84d 100644 --- a/tests/test_unknown_length.py +++ b/tests/test_unknown_length.py @@ -2,8 +2,10 @@ def test_unknown_length(): - pb = progressbar.ProgressBar(widgets=[progressbar.AnimatedMarker()], - max_value=progressbar.UnknownLength) + pb = progressbar.ProgressBar( + widgets=[progressbar.AnimatedMarker()], + max_value=progressbar.UnknownLength, + ) assert pb.max_value is progressbar.UnknownLength @@ -25,4 +27,4 @@ def test_unknown_length_at_start(): pb2 = progressbar.ProgressBar().start(max_value=progressbar.UnknownLength) for w in pb2.widgets: print(type(w), repr(w)) - assert any([isinstance(w, progressbar.Bar) for w in pb2.widgets]) + assert any(isinstance(w, progressbar.Bar) for w in pb2.widgets) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0ff4a7a1..34bd0da8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,30 +1,35 @@ import io -import pytest + import progressbar +import progressbar.env +import pytest -@pytest.mark.parametrize('value,expected', [ - (None, None), - ('', None), - ('1', True), - ('y', True), - ('t', True), - ('yes', True), - ('true', True), - ('0', False), - ('n', False), - ('f', False), - ('no', False), - ('false', False), -]) +@pytest.mark.parametrize( + 'value,expected', + [ + (None, None), + ('', None), + ('1', True), + ('y', True), + ('t', True), + ('yes', True), + ('true', True), + ('0', False), + ('n', False), + ('f', False), + ('no', False), + ('false', False), + ], +) def test_env_flag(value, expected, monkeypatch): if value is not None: monkeypatch.setenv('TEST_ENV', value) - assert progressbar.utils.env_flag('TEST_ENV') == expected + assert progressbar.env.env_flag('TEST_ENV') == expected if value: monkeypatch.setenv('TEST_ENV', value.upper()) - assert progressbar.utils.env_flag('TEST_ENV') == expected + assert progressbar.env.env_flag('TEST_ENV') == expected monkeypatch.undo() @@ -35,25 +40,25 @@ def test_is_terminal(monkeypatch): monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) monkeypatch.delenv('JPY_PARENT_PID', raising=False) - assert progressbar.utils.is_terminal(fd) is False - assert progressbar.utils.is_terminal(fd, True) is True - assert progressbar.utils.is_terminal(fd, False) is False + assert progressbar.env.is_terminal(fd) is False + assert progressbar.env.is_terminal(fd, True) is True + assert progressbar.env.is_terminal(fd, False) is False monkeypatch.setenv('JPY_PARENT_PID', '123') - assert progressbar.utils.is_terminal(fd) is True + assert progressbar.env.is_terminal(fd) is True monkeypatch.delenv('JPY_PARENT_PID') # Sanity check - assert progressbar.utils.is_terminal(fd) is False + assert progressbar.env.is_terminal(fd) is False monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') - assert progressbar.utils.is_terminal(fd) is True + assert progressbar.env.is_terminal(fd) is True monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') - assert progressbar.utils.is_terminal(fd) is False + assert progressbar.env.is_terminal(fd) is False monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') # Sanity check - assert progressbar.utils.is_terminal(fd) is False + assert progressbar.env.is_terminal(fd) is False def test_is_ansi_terminal(monkeypatch): @@ -62,22 +67,44 @@ def test_is_ansi_terminal(monkeypatch): monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL', raising=False) monkeypatch.delenv('JPY_PARENT_PID', raising=False) - assert progressbar.utils.is_ansi_terminal(fd) is False - assert progressbar.utils.is_ansi_terminal(fd, True) is True - assert progressbar.utils.is_ansi_terminal(fd, False) is False + assert progressbar.env.is_ansi_terminal(fd) is False + assert progressbar.env.is_ansi_terminal(fd, True) is True + assert progressbar.env.is_ansi_terminal(fd, False) is False monkeypatch.setenv('JPY_PARENT_PID', '123') - assert progressbar.utils.is_ansi_terminal(fd) is True + assert progressbar.env.is_ansi_terminal(fd) is True monkeypatch.delenv('JPY_PARENT_PID') # Sanity check - assert progressbar.utils.is_ansi_terminal(fd) is False + assert progressbar.env.is_ansi_terminal(fd) is False monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'true') - assert progressbar.utils.is_ansi_terminal(fd) is False + assert progressbar.env.is_ansi_terminal(fd) is False monkeypatch.setenv('PROGRESSBAR_IS_TERMINAL', 'false') - assert progressbar.utils.is_ansi_terminal(fd) is False + assert progressbar.env.is_ansi_terminal(fd) is False monkeypatch.delenv('PROGRESSBAR_IS_TERMINAL') # Sanity check - assert progressbar.utils.is_ansi_terminal(fd) is False + assert progressbar.env.is_ansi_terminal(fd) is False + + # Fake TTY mode for environment testing + fd.isatty = lambda: True + monkeypatch.setenv('TERM', 'xterm') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-256') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-256color') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.setenv('TERM', 'xterm-24bit') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.delenv('TERM') + + monkeypatch.setenv('ANSICON', 'true') + assert progressbar.env.is_ansi_terminal(fd) is True + monkeypatch.delenv('ANSICON') + assert progressbar.env.is_ansi_terminal(fd) is False + + def raise_error(): + raise RuntimeError('test') + fd.isatty = raise_error + assert progressbar.env.is_ansi_terminal(fd) is False diff --git a/tests/test_widgets.py b/tests/test_widgets.py index a38574da..9872f0be 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,7 +1,7 @@ import time -import pytest -import progressbar +import progressbar +import pytest max_values = [None, 10, progressbar.UnknownLength] @@ -35,7 +35,7 @@ def test_widgets_small_values(): p.finish() -@pytest.mark.parametrize('max_value', [10 ** 6, 10 ** 8]) +@pytest.mark.parametrize('max_value', [10**6, 10**8]) def test_widgets_large_values(max_value): widgets = [ 'Test: ', @@ -50,19 +50,19 @@ def test_widgets_large_values(max_value): progressbar.FileTransferSpeed(), ] p = progressbar.ProgressBar(widgets=widgets, max_value=max_value).start() - for i in range(0, 10 ** 6, 10 ** 4): + for i in range(0, 10**6, 10**4): time.sleep(1) p.update(i + 1) p.finish() def test_format_widget(): - widgets = [] - for mapping in progressbar.FormatLabel.mapping: - widgets.append(progressbar.FormatLabel('%%(%s)r' % mapping)) - + widgets = [ + progressbar.FormatLabel('%%(%s)r' % mapping) + for mapping in progressbar.FormatLabel.mapping + ] p = progressbar.ProgressBar(widgets=widgets) - for i in p(range(10)): + for _ in p(range(10)): time.sleep(1) @@ -95,7 +95,7 @@ def test_all_widgets_small_values(max_value): p.finish() -@pytest.mark.parametrize('max_value', [10 ** 6, 10 ** 7]) +@pytest.mark.parametrize('max_value', [10**6, 10**7]) def test_all_widgets_large_values(max_value): widgets = [ progressbar.Timer(), @@ -120,7 +120,7 @@ def test_all_widgets_large_values(max_value): time.sleep(1) p.update() - for i in range(0, 10 ** 6, 10 ** 4): + for i in range(0, 10**6, 10**4): time.sleep(1) p.update(i) @@ -144,8 +144,11 @@ def test_all_widgets_min_width(min_width, term_width): progressbar.Bar(min_width=min_width), progressbar.ReverseBar(min_width=min_width), progressbar.BouncingBar(min_width=min_width), - progressbar.FormatCustomText('Custom %(text)s', dict(text='text'), - min_width=min_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + min_width=min_width, + ), progressbar.DynamicMessage('custom', min_width=min_width), progressbar.CurrentTime(min_width=min_width), ] @@ -178,8 +181,11 @@ def test_all_widgets_max_width(max_width, term_width): progressbar.Bar(max_width=max_width), progressbar.ReverseBar(max_width=max_width), progressbar.BouncingBar(max_width=max_width), - progressbar.FormatCustomText('Custom %(text)s', dict(text='text'), - max_width=max_width), + progressbar.FormatCustomText( + 'Custom %(text)s', + dict(text='text'), + max_width=max_width, + ), progressbar.DynamicMessage('custom', max_width=max_width), progressbar.CurrentTime(max_width=max_width), ] diff --git a/tests/test_with.py b/tests/test_with.py index 1fd2a1f6..a7c60239 100644 --- a/tests/test_with.py +++ b/tests/test_with.py @@ -17,4 +17,3 @@ def test_with_extra_start(): with progressbar.ProgressBar(max_value=10) as p: p.start() p.start() - diff --git a/tests/test_wrappingio.py b/tests/test_wrappingio.py index 8a352872..b868321c 100644 --- a/tests/test_wrappingio.py +++ b/tests/test_wrappingio.py @@ -2,7 +2,6 @@ import sys import pytest - from progressbar import utils diff --git a/tox.ini b/tox.ini index f169ee95..a554606a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,22 @@ [tox] -envlist = py38, py39, py310, py311, py312, flake8, docs, black, mypy, pyright +envlist = + py38 + py39 + py310 + py311 + docs + black + ; mypy + pyright + ruff + ; codespell skip_missing_interpreters = True [testenv] -basepython = - py38: python3.8 - py39: python3.9 - py310: python3.10 - py311: python3.11 - py312: python3.12 - pypy3: pypy3 - deps = -r{toxinidir}/tests/requirements.txt commands = py.test --basetemp="{envtmpdir}" --confcutdir=.. {posargs} -changedir = tests - -[testenv:flake8] -changedir = -basepython = python3 -deps = flake8 -commands = flake8 {toxinidir}/progressbar {toxinidir}/tests {toxinidir}/examples.py +;changedir = tests +skip_install = true [testenv:mypy] changedir = @@ -30,7 +27,9 @@ commands = mypy {toxinidir}/progressbar [testenv:pyright] changedir = basepython = python3 -deps = pyright +deps = + pyright + python_utils commands = pyright {toxinidir}/progressbar [testenv:black] @@ -43,6 +42,9 @@ changedir = basepython = python3 deps = -r{toxinidir}/docs/requirements.txt allowlist_externals = + rm + mkdir +whitelist_externals = rm cd mkdir @@ -51,13 +53,16 @@ commands = mkdir -p docs/_static sphinx-apidoc -e -o docs/ progressbar rm -f docs/modules.rst - rm -f docs/progressbar.rst - sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html {posargs} + sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html {posargs} -[flake8] -ignore = W391, W504, E741, W503, E131 -exclude = - docs, - progressbar/six.py - tests/original_examples.py +[testenv:ruff] +commands = ruff check progressbar tests +deps = ruff +skip_install = true +[testenv:codespell] +changedir = {toxinidir} +commands = codespell . +deps = codespell +skip_install = true +command = codespell