├── .coveragerc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── api.rst ├── changelog.rst ├── conf.py ├── images │ ├── ansi-demo.png │ ├── html-to-ansi.png │ ├── pretty-table.png │ ├── spinner-basic.gif │ ├── spinner-with-progress.gif │ └── spinner-with-timer.gif ├── index.rst └── readme.rst ├── humanfriendly ├── __init__.py ├── case.py ├── cli.py ├── compat.py ├── decorators.py ├── deprecation.py ├── prompts.py ├── sphinx.py ├── tables.py ├── terminal │ ├── __init__.py │ ├── html.py │ └── spinners.py ├── testing.py ├── tests.py ├── text.py └── usage.py ├── requirements-checks.txt ├── requirements-tests.txt ├── requirements-travis.txt ├── scripts └── travis.sh ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | parallel = True 4 | concurrency = multiprocessing 5 | source = humanfriendly 6 | omit = humanfriendly/tests.py 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10-dev", "pypy3"] 15 | os: [ubuntu-latest] 16 | include: 17 | - { python-version: "3.7", os: macos-latest } 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Get pip cache dir 28 | id: pip-cache 29 | run: | 30 | echo "::set-output name=dir::$(pip cache dir)" 31 | 32 | - name: Cache 33 | uses: actions/cache@v2 34 | with: 35 | path: ${{ steps.pip-cache.outputs.dir }} 36 | key: 37 | ${{ matrix.os }}-${{ matrix.python-version }}-v1-${{ 38 | hashFiles('**/setup.py') }} 39 | restore-keys: | 40 | ${{ matrix.os }}-${{ matrix.python-version }}-v1- 41 | 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install -U pip 45 | python -m pip install -U setuptools virtualenv 46 | python -m pip install -U --requirement=requirements-travis.txt 47 | python -m pip install . 48 | 49 | - name: Tests 50 | run: | 51 | make check 52 | make test 53 | 54 | - name: Upload coverage 55 | uses: codecov/codecov-action@v1 56 | with: 57 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .coverage 4 | .coverage.* 5 | .tox 6 | __pycache__/ 7 | dist/*.tar.gz 8 | dist/*.zip 9 | docs/_build/ 10 | docs/_static/ 11 | docs/_templates/ 12 | docs/build/ 13 | htmlcov/ 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | graft docs 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for the 'humanfriendly' package. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 1, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | PACKAGE_NAME = humanfriendly 8 | WORKON_HOME ?= $(HOME)/.virtualenvs 9 | VIRTUAL_ENV ?= $(WORKON_HOME)/$(PACKAGE_NAME) 10 | PYTHON ?= python3 11 | PATH := $(VIRTUAL_ENV)/bin:$(PATH) 12 | MAKE := $(MAKE) --no-print-directory 13 | SHELL = bash 14 | 15 | default: 16 | @echo "Makefile for $(PACKAGE_NAME)" 17 | @echo 18 | @echo 'Usage:' 19 | @echo 20 | @echo ' make install install the package in a virtual environment' 21 | @echo ' make reset recreate the virtual environment' 22 | @echo ' make check check coding style (PEP-8, PEP-257)' 23 | @echo ' make test run the test suite, report coverage' 24 | @echo ' make tox run the tests on all Python versions' 25 | @echo ' make readme update usage in readme' 26 | @echo ' make docs update documentation using Sphinx' 27 | @echo ' make publish publish changes to GitHub/PyPI' 28 | @echo ' make clean cleanup all temporary files' 29 | @echo 30 | 31 | install: 32 | @test -d "$(VIRTUAL_ENV)" || mkdir -p "$(VIRTUAL_ENV)" 33 | @test -x "$(VIRTUAL_ENV)/bin/python" || virtualenv --python=$(PYTHON) --quiet "$(VIRTUAL_ENV)" 34 | @pip uninstall --yes $(PACKAGE_NAME) &>/dev/null || true 35 | @pip install --quiet --no-deps --ignore-installed . 36 | 37 | reset: 38 | @$(MAKE) clean 39 | @rm -Rf "$(VIRTUAL_ENV)" 40 | @$(MAKE) install 41 | 42 | check: install 43 | @pip install --upgrade --quiet --requirement=requirements-checks.txt 44 | @flake8 45 | 46 | test: install 47 | @pip install --quiet --requirement=requirements-tests.txt 48 | @py.test --cov 49 | @coverage combine || true 50 | @coverage html 51 | 52 | tox: install 53 | @pip install --quiet tox 54 | @tox 55 | 56 | readme: install 57 | @pip install --quiet cogapp 58 | @cog.py -r README.rst 59 | 60 | docs: readme 61 | @pip install --quiet sphinx 62 | @cd docs && sphinx-build -nWb html -d build/doctrees . build/html 63 | 64 | publish: install 65 | @git push origin && git push --tags origin 66 | @$(MAKE) clean 67 | @pip install --quiet twine wheel 68 | @python setup.py sdist bdist_wheel 69 | @twine upload dist/* 70 | @$(MAKE) clean 71 | 72 | clean: 73 | @rm -Rf *.egg .cache .coverage .tox build dist docs/build htmlcov 74 | @find -depth -type d -name __pycache__ -exec rm -Rf {} \; 75 | @find -type f -name '*.pyc' -delete 76 | 77 | .PHONY: default install reset check test tox readme docs publish clean 78 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | humanfriendly: Human friendly input/output in Python 2 | ==================================================== 3 | 4 | .. image:: https://github.com/xolox/python-humanfriendly/actions/workflows/test.yml/badge.svg?branch=master 5 | :target: https://github.com/xolox/python-humanfriendly/actions 6 | 7 | .. image:: https://codecov.io/gh/xolox/python-humanfriendly/branch/master/graph/badge.svg?token=jYaj4T74TU 8 | :target: https://codecov.io/gh/xolox/python-humanfriendly 9 | 10 | The functions and classes in the `humanfriendly` package can be used to make 11 | text interfaces more user friendly. Some example features: 12 | 13 | - Parsing and formatting numbers, file sizes, pathnames and timespans in 14 | simple, human friendly formats. 15 | 16 | - Easy to use timers for long running operations, with human friendly 17 | formatting of the resulting timespans. 18 | 19 | - Prompting the user to select a choice from a list of options by typing the 20 | option's number or a unique substring of the option. 21 | 22 | - Terminal interaction including text styling (`ANSI escape sequences`_), user 23 | friendly rendering of usage messages and querying the terminal for its 24 | size. 25 | 26 | The `humanfriendly` package is currently tested on Python 2.7, 3.5+ and PyPy 27 | (2.7) on Linux and macOS. While the intention is to support Windows as well, 28 | you may encounter some rough edges. 29 | 30 | .. contents:: 31 | :local: 32 | 33 | Getting started 34 | --------------- 35 | 36 | It's very simple to start using the `humanfriendly` package:: 37 | 38 | >>> from humanfriendly import format_size, parse_size 39 | >>> from humanfriendly.prompts import prompt_for_input 40 | >>> user_input = prompt_for_input("Enter a readable file size: ") 41 | 42 | Enter a readable file size: 16G 43 | 44 | >>> num_bytes = parse_size(user_input) 45 | >>> print(num_bytes) 46 | 16000000000 47 | >>> print("You entered:", format_size(num_bytes)) 48 | You entered: 16 GB 49 | >>> print("You entered:", format_size(num_bytes, binary=True)) 50 | You entered: 14.9 GiB 51 | 52 | To get a demonstration of supported terminal text styles (based on 53 | `ANSI escape sequences`_) you can run the following command:: 54 | 55 | $ humanfriendly --demo 56 | 57 | Command line 58 | ------------ 59 | 60 | .. A DRY solution to avoid duplication of the `humanfriendly --help' text: 61 | .. 62 | .. [[[cog 63 | .. from humanfriendly.usage import inject_usage 64 | .. inject_usage('humanfriendly.cli') 65 | .. ]]] 66 | 67 | **Usage:** `humanfriendly [OPTIONS]` 68 | 69 | Human friendly input/output (text formatting) on the command 70 | line based on the Python package with the same name. 71 | 72 | **Supported options:** 73 | 74 | .. csv-table:: 75 | :header: Option, Description 76 | :widths: 30, 70 77 | 78 | 79 | "``-c``, ``--run-command``","Execute an external command (given as the positional arguments) and render 80 | a spinner and timer while the command is running. The exit status of the 81 | command is propagated." 82 | ``--format-table``,"Read tabular data from standard input (each line is a row and each 83 | whitespace separated field is a column), format the data as a table and 84 | print the resulting table to standard output. See also the ``--delimiter`` 85 | option." 86 | "``-d``, ``--delimiter=VALUE``","Change the delimiter used by ``--format-table`` to ``VALUE`` (a string). By default 87 | all whitespace is treated as a delimiter." 88 | "``-l``, ``--format-length=LENGTH``","Convert a length count (given as the integer or float ``LENGTH``) into a human 89 | readable string and print that string to standard output." 90 | "``-n``, ``--format-number=VALUE``","Format a number (given as the integer or floating point number ``VALUE``) with 91 | thousands separators and two decimal places (if needed) and print the 92 | formatted number to standard output." 93 | "``-s``, ``--format-size=BYTES``","Convert a byte count (given as the integer ``BYTES``) into a human readable 94 | string and print that string to standard output." 95 | "``-b``, ``--binary``","Change the output of ``-s``, ``--format-size`` to use binary multiples of bytes 96 | (base-2) instead of the default decimal multiples of bytes (base-10)." 97 | "``-t``, ``--format-timespan=SECONDS``","Convert a number of seconds (given as the floating point number ``SECONDS``) 98 | into a human readable timespan and print that string to standard output." 99 | ``--parse-length=VALUE``,"Parse a human readable length (given as the string ``VALUE``) and print the 100 | number of metres to standard output." 101 | ``--parse-size=VALUE``,"Parse a human readable data size (given as the string ``VALUE``) and print the 102 | number of bytes to standard output." 103 | ``--demo``,"Demonstrate changing the style and color of the terminal font using ANSI 104 | escape sequences." 105 | "``-h``, ``--help``",Show this message and exit. 106 | 107 | .. [[[end]]] 108 | 109 | A note about size units 110 | ----------------------- 111 | 112 | When I originally published the `humanfriendly` package I went with binary 113 | multiples of bytes (powers of two). It was pointed out several times that this 114 | was a poor choice (see issue `#4`_ and pull requests `#8`_ and `#9`_) and thus 115 | the new default became decimal multiples of bytes (powers of ten): 116 | 117 | +------+---------------+---------------+ 118 | | Unit | Binary value | Decimal value | 119 | +------+---------------+---------------+ 120 | | KB | 1024 | 1000 + 121 | +------+---------------+---------------+ 122 | | MB | 1048576 | 1000000 | 123 | +------+---------------+---------------+ 124 | | GB | 1073741824 | 1000000000 | 125 | +------+---------------+---------------+ 126 | | TB | 1099511627776 | 1000000000000 | 127 | +------+---------------+---------------+ 128 | | etc | | | 129 | +------+---------------+---------------+ 130 | 131 | The option to use binary multiples of bytes remains by passing the keyword 132 | argument `binary=True` to the `format_size()`_ and `parse_size()`_ functions. 133 | 134 | Windows support 135 | --------------- 136 | 137 | Windows 10 gained native support for ANSI escape sequences which means commands 138 | like ``humanfriendly --demo`` should work out of the box (if your system is 139 | up-to-date enough). If this doesn't work then you can install the colorama_ 140 | package, it will be used automatically once installed. 141 | 142 | Contact 143 | ------- 144 | 145 | The latest version of `humanfriendly` is available on PyPI_ and GitHub_. The 146 | documentation is hosted on `Read the Docs`_ and includes a changelog_. For bug 147 | reports please create an issue on GitHub_. If you have questions, suggestions, 148 | etc. feel free to send me an e-mail at `peter@peterodding.com`_. 149 | 150 | License 151 | ------- 152 | 153 | This software is licensed under the `MIT license`_. 154 | 155 | © 2021 Peter Odding. 156 | 157 | .. External references: 158 | .. _#4: https://github.com/xolox/python-humanfriendly/issues/4 159 | .. _#8: https://github.com/xolox/python-humanfriendly/pull/8 160 | .. _#9: https://github.com/xolox/python-humanfriendly/pull/9 161 | .. _ANSI escape sequences: https://en.wikipedia.org/wiki/ANSI_escape_code 162 | .. _changelog: https://humanfriendly.readthedocs.io/en/latest/changelog.html 163 | .. _colorama: https://pypi.org/project/colorama 164 | .. _format_size(): https://humanfriendly.readthedocs.io/en/latest/#humanfriendly.format_size 165 | .. _GitHub: https://github.com/xolox/python-humanfriendly 166 | .. _MIT license: https://en.wikipedia.org/wiki/MIT_License 167 | .. _parse_size(): https://humanfriendly.readthedocs.io/en/latest/#humanfriendly.parse_size 168 | .. _peter@peterodding.com: peter@peterodding.com 169 | .. _PyPI: https://pypi.org/project/humanfriendly 170 | .. _Read the Docs: https://humanfriendly.readthedocs.io 171 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | The following API documentation was automatically generated from the source 5 | code of `humanfriendly` |release|: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | A note about backwards compatibility 11 | ------------------------------------ 12 | 13 | The `humanfriendly` package started out as a single :mod:`humanfriendly` 14 | module. Eventually this module grew to a size that necessitated splitting up 15 | the code into multiple modules (see e.g. :mod:`~humanfriendly.tables`, 16 | :mod:`~humanfriendly.terminal`, :mod:`~humanfriendly.text` and 17 | :mod:`~humanfriendly.usage`). Most of the functionality that remains in the 18 | :mod:`humanfriendly` module will eventually be moved to submodules as well (as 19 | time permits and a logical subdivision of functionality presents itself to me). 20 | 21 | While moving functionality around like this my goal is to always preserve 22 | backwards compatibility. For example if a function is moved to a submodule an 23 | import of that function is added in the main module so that backwards 24 | compatibility with previously written import statements is preserved. 25 | 26 | If backwards compatibility of documented functionality has to be broken then 27 | the major version number will be bumped. So if you're using the `humanfriendly` 28 | package in your project, make sure to at least pin the major version number in 29 | order to avoid unexpected surprises. 30 | 31 | :mod:`humanfriendly` 32 | -------------------- 33 | 34 | .. automodule:: humanfriendly 35 | :members: 36 | 37 | :mod:`humanfriendly.case` 38 | ------------------------- 39 | 40 | .. automodule:: humanfriendly.case 41 | :members: 42 | 43 | :mod:`humanfriendly.cli` 44 | ------------------------ 45 | 46 | .. automodule:: humanfriendly.cli 47 | :members: 48 | 49 | :mod:`humanfriendly.compat` 50 | --------------------------- 51 | 52 | .. automodule:: humanfriendly.compat 53 | :members: coerce_string, is_string, is_unicode, on_macos, on_windows 54 | 55 | .. The members above are defined explicitly so that Sphinx does not 56 | .. embed documentation for all of the standard library aliases. 57 | 58 | :mod:`humanfriendly.decorators` 59 | ------------------------------- 60 | 61 | .. automodule:: humanfriendly.decorators 62 | :members: 63 | 64 | :mod:`humanfriendly.deprecation` 65 | -------------------------------- 66 | 67 | .. automodule:: humanfriendly.deprecation 68 | :members: 69 | 70 | :mod:`humanfriendly.prompts` 71 | ---------------------------- 72 | 73 | .. automodule:: humanfriendly.prompts 74 | :members: 75 | 76 | :mod:`humanfriendly.sphinx` 77 | --------------------------- 78 | 79 | .. automodule:: humanfriendly.sphinx 80 | :members: 81 | 82 | :mod:`humanfriendly.tables` 83 | --------------------------- 84 | 85 | .. automodule:: humanfriendly.tables 86 | :members: 87 | 88 | :mod:`humanfriendly.terminal` 89 | ----------------------------- 90 | 91 | .. automodule:: humanfriendly.terminal 92 | :members: 93 | 94 | :mod:`humanfriendly.terminal.html` 95 | ---------------------------------- 96 | 97 | .. automodule:: humanfriendly.terminal.html 98 | :members: 99 | 100 | :mod:`humanfriendly.terminal.spinners` 101 | -------------------------------------- 102 | 103 | .. automodule:: humanfriendly.terminal.spinners 104 | :members: 105 | 106 | :mod:`humanfriendly.testing` 107 | ---------------------------- 108 | 109 | .. automodule:: humanfriendly.testing 110 | :members: 111 | 112 | :mod:`humanfriendly.text` 113 | ------------------------- 114 | 115 | .. automodule:: humanfriendly.text 116 | :members: 117 | 118 | :mod:`humanfriendly.usage` 119 | -------------------------- 120 | 121 | .. automodule:: humanfriendly.usage 122 | :members: 123 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Documentation build configuration file for the `humanfriendly` package.""" 4 | 5 | import os 6 | import sys 7 | 8 | # Add the 'humanfriendly' source distribution's root directory to the module path. 9 | sys.path.insert(0, os.path.abspath('..')) 10 | 11 | # -- General configuration ----------------------------------------------------- 12 | 13 | # Sphinx extension module names. 14 | extensions = [ 15 | 'sphinx.ext.doctest', 16 | 'sphinx.ext.autodoc', 17 | 'sphinx.ext.intersphinx', 18 | 'humanfriendly.sphinx', 19 | ] 20 | 21 | # Configuration for the `autodoc' extension. 22 | autodoc_member_order = 'bysource' 23 | 24 | # Paths that contain templates, relative to this directory. 25 | templates_path = ['templates'] 26 | 27 | # The suffix of source filenames. 28 | source_suffix = '.rst' 29 | 30 | # The master toctree document. 31 | master_doc = 'index' 32 | 33 | # General information about the project. 34 | project = 'humanfriendly' 35 | copyright = '2021, Peter Odding' 36 | 37 | # The version info for the project you're documenting, acts as replacement for 38 | # |version| and |release|, also used in various other places throughout the 39 | # built documents. 40 | 41 | # Find the package version and make it the release. 42 | from humanfriendly import __version__ as humanfriendly_version # noqa 43 | 44 | # The short X.Y version. 45 | version = '.'.join(humanfriendly_version.split('.')[:2]) 46 | 47 | # The full version, including alpha/beta/rc tags. 48 | release = humanfriendly_version 49 | 50 | # The language for content autogenerated by Sphinx. Refer to documentation 51 | # for a list of supported languages. 52 | language = 'en' 53 | 54 | # List of patterns, relative to source directory, that match files and 55 | # directories to ignore when looking for source files. 56 | exclude_patterns = ['build'] 57 | 58 | # If true, '()' will be appended to :func: etc. cross-reference text. 59 | add_function_parentheses = True 60 | 61 | # The name of the Pygments (syntax highlighting) style to use. 62 | pygments_style = 'sphinx' 63 | 64 | # Refer to the Python standard library. 65 | # From: http://twistedmatrix.com/trac/ticket/4582. 66 | intersphinx_mapping = dict( 67 | python2=('https://docs.python.org/2', None), 68 | python3=('https://docs.python.org/3', None), 69 | coloredlogs=('https://coloredlogs.readthedocs.io/en/latest/', None), 70 | ) 71 | 72 | # -- Options for HTML output --------------------------------------------------- 73 | 74 | # The theme to use for HTML and HTML Help pages. See the documentation for 75 | # a list of builtin themes. 76 | html_theme = 'nature' 77 | -------------------------------------------------------------------------------- /docs/images/ansi-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/ansi-demo.png -------------------------------------------------------------------------------- /docs/images/html-to-ansi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/html-to-ansi.png -------------------------------------------------------------------------------- /docs/images/pretty-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/pretty-table.png -------------------------------------------------------------------------------- /docs/images/spinner-basic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/spinner-basic.gif -------------------------------------------------------------------------------- /docs/images/spinner-with-progress.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/spinner-with-progress.gif -------------------------------------------------------------------------------- /docs/images/spinner-with-timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/python-humanfriendly/6758ac61f906cd8528682003070a57febe4ad3cf/docs/images/spinner-with-timer.gif -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | humanfriendly: Human friendly input/output in Python 2 | ==================================================== 3 | 4 | Welcome to the documentation of `humanfriendly` version |release|! The 5 | following sections are available: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | User documentation 11 | ------------------ 12 | 13 | The readme is the best place to start reading, it's targeted at all users and 14 | documents the command line interface: 15 | 16 | .. toctree:: 17 | readme.rst 18 | 19 | API documentation 20 | ----------------- 21 | 22 | The following API documentation is automatically generated from the source code: 23 | 24 | .. toctree:: 25 | api.rst 26 | 27 | Change log 28 | ---------- 29 | 30 | The change log lists notable changes to the project: 31 | 32 | .. toctree:: 33 | changelog.rst 34 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /humanfriendly/case.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: April 19, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Simple case insensitive dictionaries. 9 | 10 | The :class:`CaseInsensitiveDict` class is a dictionary whose string keys 11 | are case insensitive. It works by automatically coercing string keys to 12 | :class:`CaseInsensitiveKey` objects. Keys that are not strings are 13 | supported as well, just without case insensitivity. 14 | 15 | At its core this module works by normalizing strings to lowercase before 16 | comparing or hashing them. It doesn't support proper case folding nor 17 | does it support Unicode normalization, hence the word "simple". 18 | """ 19 | 20 | # Standard library modules. 21 | import collections 22 | 23 | try: 24 | # Python >= 3.3. 25 | from collections.abc import Iterable, Mapping 26 | except ImportError: 27 | # Python 2.7. 28 | from collections import Iterable, Mapping 29 | 30 | # Modules included in our package. 31 | from humanfriendly.compat import basestring, unicode 32 | 33 | # Public identifiers that require documentation. 34 | __all__ = ("CaseInsensitiveDict", "CaseInsensitiveKey") 35 | 36 | 37 | class CaseInsensitiveDict(collections.OrderedDict): 38 | 39 | """ 40 | Simple case insensitive dictionary implementation (that remembers insertion order). 41 | 42 | This class works by overriding methods that deal with dictionary keys to 43 | coerce string keys to :class:`CaseInsensitiveKey` objects before calling 44 | down to the regular dictionary handling methods. While intended to be 45 | complete this class has not been extensively tested yet. 46 | """ 47 | 48 | def __init__(self, other=None, **kw): 49 | """Initialize a :class:`CaseInsensitiveDict` object.""" 50 | # Initialize our superclass. 51 | super(CaseInsensitiveDict, self).__init__() 52 | # Handle the initializer arguments. 53 | self.update(other, **kw) 54 | 55 | def coerce_key(self, key): 56 | """ 57 | Coerce string keys to :class:`CaseInsensitiveKey` objects. 58 | 59 | :param key: The value to coerce (any type). 60 | :returns: If `key` is a string then a :class:`CaseInsensitiveKey` 61 | object is returned, otherwise the value of `key` is 62 | returned unmodified. 63 | """ 64 | if isinstance(key, basestring): 65 | key = CaseInsensitiveKey(key) 66 | return key 67 | 68 | @classmethod 69 | def fromkeys(cls, iterable, value=None): 70 | """Create a case insensitive dictionary with keys from `iterable` and values set to `value`.""" 71 | return cls((k, value) for k in iterable) 72 | 73 | def get(self, key, default=None): 74 | """Get the value of an existing item.""" 75 | return super(CaseInsensitiveDict, self).get(self.coerce_key(key), default) 76 | 77 | def pop(self, key, default=None): 78 | """Remove an item from a case insensitive dictionary.""" 79 | return super(CaseInsensitiveDict, self).pop(self.coerce_key(key), default) 80 | 81 | def setdefault(self, key, default=None): 82 | """Get the value of an existing item or add a new item.""" 83 | return super(CaseInsensitiveDict, self).setdefault(self.coerce_key(key), default) 84 | 85 | def update(self, other=None, **kw): 86 | """Update a case insensitive dictionary with new items.""" 87 | if isinstance(other, Mapping): 88 | # Copy the items from the given mapping. 89 | for key, value in other.items(): 90 | self[key] = value 91 | elif isinstance(other, Iterable): 92 | # Copy the items from the given iterable. 93 | for key, value in other: 94 | self[key] = value 95 | elif other is not None: 96 | # Complain about unsupported values. 97 | msg = "'%s' object is not iterable" 98 | type_name = type(value).__name__ 99 | raise TypeError(msg % type_name) 100 | # Copy the keyword arguments (if any). 101 | for key, value in kw.items(): 102 | self[key] = value 103 | 104 | def __contains__(self, key): 105 | """Check if a case insensitive dictionary contains the given key.""" 106 | return super(CaseInsensitiveDict, self).__contains__(self.coerce_key(key)) 107 | 108 | def __delitem__(self, key): 109 | """Delete an item in a case insensitive dictionary.""" 110 | return super(CaseInsensitiveDict, self).__delitem__(self.coerce_key(key)) 111 | 112 | def __getitem__(self, key): 113 | """Get the value of an item in a case insensitive dictionary.""" 114 | return super(CaseInsensitiveDict, self).__getitem__(self.coerce_key(key)) 115 | 116 | def __setitem__(self, key, value): 117 | """Set the value of an item in a case insensitive dictionary.""" 118 | return super(CaseInsensitiveDict, self).__setitem__(self.coerce_key(key), value) 119 | 120 | 121 | class CaseInsensitiveKey(unicode): 122 | 123 | """ 124 | Simple case insensitive dictionary key implementation. 125 | 126 | The :class:`CaseInsensitiveKey` class provides an intentionally simple 127 | implementation of case insensitive strings to be used as dictionary keys. 128 | 129 | If you need features like Unicode normalization or proper case folding 130 | please consider using a more advanced implementation like the :pypi:`istr` 131 | package instead. 132 | """ 133 | 134 | def __new__(cls, value): 135 | """Create a :class:`CaseInsensitiveKey` object.""" 136 | # Delegate string object creation to our superclass. 137 | obj = unicode.__new__(cls, value) 138 | # Store the lowercased string and its hash value. 139 | normalized = obj.lower() 140 | obj._normalized = normalized 141 | obj._hash_value = hash(normalized) 142 | return obj 143 | 144 | def __hash__(self): 145 | """Get the hash value of the lowercased string.""" 146 | return self._hash_value 147 | 148 | def __eq__(self, other): 149 | """Compare two strings as lowercase.""" 150 | if isinstance(other, CaseInsensitiveKey): 151 | # Fast path (and the most common case): Comparison with same type. 152 | return self._normalized == other._normalized 153 | elif isinstance(other, unicode): 154 | # Slow path: Comparison with strings that need lowercasing. 155 | return self._normalized == other.lower() 156 | else: 157 | return NotImplemented 158 | -------------------------------------------------------------------------------- /humanfriendly/cli.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 1, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Usage: humanfriendly [OPTIONS] 9 | 10 | Human friendly input/output (text formatting) on the command 11 | line based on the Python package with the same name. 12 | 13 | Supported options: 14 | 15 | -c, --run-command 16 | 17 | Execute an external command (given as the positional arguments) and render 18 | a spinner and timer while the command is running. The exit status of the 19 | command is propagated. 20 | 21 | --format-table 22 | 23 | Read tabular data from standard input (each line is a row and each 24 | whitespace separated field is a column), format the data as a table and 25 | print the resulting table to standard output. See also the --delimiter 26 | option. 27 | 28 | -d, --delimiter=VALUE 29 | 30 | Change the delimiter used by --format-table to VALUE (a string). By default 31 | all whitespace is treated as a delimiter. 32 | 33 | -l, --format-length=LENGTH 34 | 35 | Convert a length count (given as the integer or float LENGTH) into a human 36 | readable string and print that string to standard output. 37 | 38 | -n, --format-number=VALUE 39 | 40 | Format a number (given as the integer or floating point number VALUE) with 41 | thousands separators and two decimal places (if needed) and print the 42 | formatted number to standard output. 43 | 44 | -s, --format-size=BYTES 45 | 46 | Convert a byte count (given as the integer BYTES) into a human readable 47 | string and print that string to standard output. 48 | 49 | -b, --binary 50 | 51 | Change the output of -s, --format-size to use binary multiples of bytes 52 | (base-2) instead of the default decimal multiples of bytes (base-10). 53 | 54 | -t, --format-timespan=SECONDS 55 | 56 | Convert a number of seconds (given as the floating point number SECONDS) 57 | into a human readable timespan and print that string to standard output. 58 | 59 | --parse-length=VALUE 60 | 61 | Parse a human readable length (given as the string VALUE) and print the 62 | number of metres to standard output. 63 | 64 | --parse-size=VALUE 65 | 66 | Parse a human readable data size (given as the string VALUE) and print the 67 | number of bytes to standard output. 68 | 69 | --demo 70 | 71 | Demonstrate changing the style and color of the terminal font using ANSI 72 | escape sequences. 73 | 74 | -h, --help 75 | 76 | Show this message and exit. 77 | """ 78 | 79 | # Standard library modules. 80 | import functools 81 | import getopt 82 | import pipes 83 | import subprocess 84 | import sys 85 | 86 | # Modules included in our package. 87 | from humanfriendly import ( 88 | Timer, 89 | format_length, 90 | format_number, 91 | format_size, 92 | format_timespan, 93 | parse_length, 94 | parse_size, 95 | ) 96 | from humanfriendly.tables import format_pretty_table, format_smart_table 97 | from humanfriendly.terminal import ( 98 | ANSI_COLOR_CODES, 99 | ANSI_TEXT_STYLES, 100 | HIGHLIGHT_COLOR, 101 | ansi_strip, 102 | ansi_wrap, 103 | enable_ansi_support, 104 | find_terminal_size, 105 | output, 106 | usage, 107 | warning, 108 | ) 109 | from humanfriendly.terminal.spinners import Spinner 110 | 111 | # Public identifiers that require documentation. 112 | __all__ = ( 113 | 'demonstrate_256_colors', 114 | 'demonstrate_ansi_formatting', 115 | 'main', 116 | 'print_formatted_length', 117 | 'print_formatted_number', 118 | 'print_formatted_size', 119 | 'print_formatted_table', 120 | 'print_formatted_timespan', 121 | 'print_parsed_length', 122 | 'print_parsed_size', 123 | 'run_command', 124 | ) 125 | 126 | 127 | def main(): 128 | """Command line interface for the ``humanfriendly`` program.""" 129 | enable_ansi_support() 130 | try: 131 | options, arguments = getopt.getopt(sys.argv[1:], 'cd:l:n:s:bt:h', [ 132 | 'run-command', 'format-table', 'delimiter=', 'format-length=', 133 | 'format-number=', 'format-size=', 'binary', 'format-timespan=', 134 | 'parse-length=', 'parse-size=', 'demo', 'help', 135 | ]) 136 | except Exception as e: 137 | warning("Error: %s", e) 138 | sys.exit(1) 139 | actions = [] 140 | delimiter = None 141 | should_format_table = False 142 | binary = any(o in ('-b', '--binary') for o, v in options) 143 | for option, value in options: 144 | if option in ('-d', '--delimiter'): 145 | delimiter = value 146 | elif option == '--parse-size': 147 | actions.append(functools.partial(print_parsed_size, value)) 148 | elif option == '--parse-length': 149 | actions.append(functools.partial(print_parsed_length, value)) 150 | elif option in ('-c', '--run-command'): 151 | actions.append(functools.partial(run_command, arguments)) 152 | elif option in ('-l', '--format-length'): 153 | actions.append(functools.partial(print_formatted_length, value)) 154 | elif option in ('-n', '--format-number'): 155 | actions.append(functools.partial(print_formatted_number, value)) 156 | elif option in ('-s', '--format-size'): 157 | actions.append(functools.partial(print_formatted_size, value, binary)) 158 | elif option == '--format-table': 159 | should_format_table = True 160 | elif option in ('-t', '--format-timespan'): 161 | actions.append(functools.partial(print_formatted_timespan, value)) 162 | elif option == '--demo': 163 | actions.append(demonstrate_ansi_formatting) 164 | elif option in ('-h', '--help'): 165 | usage(__doc__) 166 | return 167 | if should_format_table: 168 | actions.append(functools.partial(print_formatted_table, delimiter)) 169 | if not actions: 170 | usage(__doc__) 171 | return 172 | for partial in actions: 173 | partial() 174 | 175 | 176 | def run_command(command_line): 177 | """Run an external command and show a spinner while the command is running.""" 178 | timer = Timer() 179 | spinner_label = "Waiting for command: %s" % " ".join(map(pipes.quote, command_line)) 180 | with Spinner(label=spinner_label, timer=timer) as spinner: 181 | process = subprocess.Popen(command_line) 182 | while True: 183 | spinner.step() 184 | spinner.sleep() 185 | if process.poll() is not None: 186 | break 187 | sys.exit(process.returncode) 188 | 189 | 190 | def print_formatted_length(value): 191 | """Print a human readable length.""" 192 | if '.' in value: 193 | output(format_length(float(value))) 194 | else: 195 | output(format_length(int(value))) 196 | 197 | 198 | def print_formatted_number(value): 199 | """Print large numbers in a human readable format.""" 200 | output(format_number(float(value))) 201 | 202 | 203 | def print_formatted_size(value, binary): 204 | """Print a human readable size.""" 205 | output(format_size(int(value), binary=binary)) 206 | 207 | 208 | def print_formatted_table(delimiter): 209 | """Read tabular data from standard input and print a table.""" 210 | data = [] 211 | for line in sys.stdin: 212 | line = line.rstrip() 213 | data.append(line.split(delimiter)) 214 | output(format_pretty_table(data)) 215 | 216 | 217 | def print_formatted_timespan(value): 218 | """Print a human readable timespan.""" 219 | output(format_timespan(float(value))) 220 | 221 | 222 | def print_parsed_length(value): 223 | """Parse a human readable length and print the number of metres.""" 224 | output(parse_length(value)) 225 | 226 | 227 | def print_parsed_size(value): 228 | """Parse a human readable data size and print the number of bytes.""" 229 | output(parse_size(value)) 230 | 231 | 232 | def demonstrate_ansi_formatting(): 233 | """Demonstrate the use of ANSI escape sequences.""" 234 | # First we demonstrate the supported text styles. 235 | output('%s', ansi_wrap('Text styles:', bold=True)) 236 | styles = ['normal', 'bright'] 237 | styles.extend(ANSI_TEXT_STYLES.keys()) 238 | for style_name in sorted(styles): 239 | options = dict(color=HIGHLIGHT_COLOR) 240 | if style_name != 'normal': 241 | options[style_name] = True 242 | style_label = style_name.replace('_', ' ').capitalize() 243 | output(' - %s', ansi_wrap(style_label, **options)) 244 | # Now we demonstrate named foreground and background colors. 245 | for color_type, color_label in (('color', 'Foreground colors'), 246 | ('background', 'Background colors')): 247 | intensities = [ 248 | ('normal', dict()), 249 | ('bright', dict(bright=True)), 250 | ] 251 | if color_type != 'background': 252 | intensities.insert(0, ('faint', dict(faint=True))) 253 | output('\n%s' % ansi_wrap('%s:' % color_label, bold=True)) 254 | output(format_smart_table([ 255 | [color_name] + [ 256 | ansi_wrap( 257 | 'XXXXXX' if color_type != 'background' else (' ' * 6), 258 | **dict(list(kw.items()) + [(color_type, color_name)]) 259 | ) for label, kw in intensities 260 | ] for color_name in sorted(ANSI_COLOR_CODES.keys()) 261 | ], column_names=['Color'] + [ 262 | label.capitalize() for label, kw in intensities 263 | ])) 264 | # Demonstrate support for 256 colors as well. 265 | demonstrate_256_colors(0, 7, 'standard colors') 266 | demonstrate_256_colors(8, 15, 'high-intensity colors') 267 | demonstrate_256_colors(16, 231, '216 colors') 268 | demonstrate_256_colors(232, 255, 'gray scale colors') 269 | 270 | 271 | def demonstrate_256_colors(i, j, group=None): 272 | """Demonstrate 256 color mode support.""" 273 | # Generate the label. 274 | label = '256 color mode' 275 | if group: 276 | label += ' (%s)' % group 277 | output('\n' + ansi_wrap('%s:' % label, bold=True)) 278 | # Generate a simple rendering of the colors in the requested range and 279 | # check if it will fit on a single line (given the terminal's width). 280 | single_line = ''.join(' ' + ansi_wrap(str(n), color=n) for n in range(i, j + 1)) 281 | lines, columns = find_terminal_size() 282 | if columns >= len(ansi_strip(single_line)): 283 | output(single_line) 284 | else: 285 | # Generate a more complex rendering of the colors that will nicely wrap 286 | # over multiple lines without using too many lines. 287 | width = len(str(j)) + 1 288 | colors_per_line = int(columns / width) 289 | colors = [ansi_wrap(str(n).rjust(width), color=n) for n in range(i, j + 1)] 290 | blocks = [colors[n:n + colors_per_line] for n in range(0, len(colors), colors_per_line)] 291 | output('\n'.join(''.join(b) for b in blocks)) 292 | -------------------------------------------------------------------------------- /humanfriendly/compat.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: September 17, 2021 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Compatibility with Python 2 and 3. 9 | 10 | This module exposes aliases and functions that make it easier to write Python 11 | code that is compatible with Python 2 and Python 3. 12 | 13 | .. data:: basestring 14 | 15 | Alias for :func:`python2:basestring` (in Python 2) or :class:`python3:str` 16 | (in Python 3). See also :func:`is_string()`. 17 | 18 | .. data:: HTMLParser 19 | 20 | Alias for :class:`python2:HTMLParser.HTMLParser` (in Python 2) or 21 | :class:`python3:html.parser.HTMLParser` (in Python 3). 22 | 23 | .. data:: interactive_prompt 24 | 25 | Alias for :func:`python2:raw_input()` (in Python 2) or 26 | :func:`python3:input()` (in Python 3). 27 | 28 | .. data:: StringIO 29 | 30 | Alias for :class:`python2:StringIO.StringIO` (in Python 2) or 31 | :class:`python3:io.StringIO` (in Python 3). 32 | 33 | .. data:: unicode 34 | 35 | Alias for :func:`python2:unicode` (in Python 2) or :class:`python3:str` (in 36 | Python 3). See also :func:`coerce_string()`. 37 | 38 | .. data:: monotonic 39 | 40 | Alias for :func:`python3:time.monotonic()` (in Python 3.3 and higher) or 41 | `monotonic.monotonic()` (a `conditional dependency 42 | `_ on older Python versions). 43 | """ 44 | 45 | __all__ = ( 46 | 'HTMLParser', 47 | 'StringIO', 48 | 'basestring', 49 | 'coerce_string', 50 | 'interactive_prompt', 51 | 'is_string', 52 | 'is_unicode', 53 | 'monotonic', 54 | 'name2codepoint', 55 | 'on_macos', 56 | 'on_windows', 57 | 'unichr', 58 | 'unicode', 59 | 'which', 60 | ) 61 | 62 | # Standard library modules. 63 | import sys 64 | 65 | # Differences between Python 2 and 3. 66 | try: 67 | # Python 2. 68 | unicode = unicode 69 | unichr = unichr 70 | basestring = basestring 71 | interactive_prompt = raw_input 72 | from distutils.spawn import find_executable as which 73 | from HTMLParser import HTMLParser 74 | from StringIO import StringIO 75 | from htmlentitydefs import name2codepoint 76 | except (ImportError, NameError): 77 | # Python 3. 78 | unicode = str 79 | unichr = chr 80 | basestring = str 81 | interactive_prompt = input 82 | from shutil import which 83 | from html.parser import HTMLParser 84 | from io import StringIO 85 | from html.entities import name2codepoint 86 | 87 | try: 88 | # Python 3.3 and higher. 89 | from time import monotonic 90 | except ImportError: 91 | # A replacement for older Python versions: 92 | # https://pypi.org/project/monotonic/ 93 | try: 94 | from monotonic import monotonic 95 | except (ImportError, RuntimeError): 96 | # We fall back to the old behavior of using time.time() instead of 97 | # failing when {time,monotonic}.monotonic() are both missing. 98 | from time import time as monotonic 99 | 100 | 101 | def coerce_string(value): 102 | """ 103 | Coerce any value to a Unicode string (:func:`python2:unicode` in Python 2 and :class:`python3:str` in Python 3). 104 | 105 | :param value: The value to coerce. 106 | :returns: The value coerced to a Unicode string. 107 | """ 108 | return value if is_string(value) else unicode(value) 109 | 110 | 111 | def is_string(value): 112 | """ 113 | Check if a value is a :func:`python2:basestring` (in Python 2) or :class:`python3:str` (in Python 3) object. 114 | 115 | :param value: The value to check. 116 | :returns: :data:`True` if the value is a string, :data:`False` otherwise. 117 | """ 118 | return isinstance(value, basestring) 119 | 120 | 121 | def is_unicode(value): 122 | """ 123 | Check if a value is a :func:`python2:unicode` (in Python 2) or :class:`python2:str` (in Python 3) object. 124 | 125 | :param value: The value to check. 126 | :returns: :data:`True` if the value is a Unicode string, :data:`False` otherwise. 127 | """ 128 | return isinstance(value, unicode) 129 | 130 | 131 | def on_macos(): 132 | """ 133 | Check if we're running on Apple MacOS. 134 | 135 | :returns: :data:`True` if running MacOS, :data:`False` otherwise. 136 | """ 137 | return sys.platform.startswith('darwin') 138 | 139 | 140 | def on_windows(): 141 | """ 142 | Check if we're running on the Microsoft Windows OS. 143 | 144 | :returns: :data:`True` if running Windows, :data:`False` otherwise. 145 | """ 146 | return sys.platform.startswith('win') 147 | -------------------------------------------------------------------------------- /humanfriendly/decorators.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """Simple function decorators to make Python programming easier.""" 8 | 9 | # Standard library modules. 10 | import functools 11 | 12 | # Public identifiers that require documentation. 13 | __all__ = ('RESULTS_ATTRIBUTE', 'cached') 14 | 15 | RESULTS_ATTRIBUTE = 'cached_results' 16 | """The name of the property used to cache the return values of functions (a string).""" 17 | 18 | 19 | def cached(function): 20 | """ 21 | Rudimentary caching decorator for functions. 22 | 23 | :param function: The function whose return value should be cached. 24 | :returns: The decorated function. 25 | 26 | The given function will only be called once, the first time the wrapper 27 | function is called. The return value is cached by the wrapper function as 28 | an attribute of the given function and returned on each subsequent call. 29 | 30 | .. note:: Currently no function arguments are supported because only a 31 | single return value can be cached. Accepting any function 32 | arguments at all would imply that the cache is parametrized on 33 | function arguments, which is not currently the case. 34 | """ 35 | @functools.wraps(function) 36 | def wrapper(): 37 | try: 38 | return getattr(wrapper, RESULTS_ATTRIBUTE) 39 | except AttributeError: 40 | result = function() 41 | setattr(wrapper, RESULTS_ATTRIBUTE, result) 42 | return result 43 | return wrapper 44 | -------------------------------------------------------------------------------- /humanfriendly/deprecation.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Support for deprecation warnings when importing names from old locations. 9 | 10 | When software evolves, things tend to move around. This is usually detrimental 11 | to backwards compatibility (in Python this primarily manifests itself as 12 | :exc:`~exceptions.ImportError` exceptions). 13 | 14 | While backwards compatibility is very important, it should not get in the way 15 | of progress. It would be great to have the agility to move things around 16 | without breaking backwards compatibility. 17 | 18 | This is where the :mod:`humanfriendly.deprecation` module comes in: It enables 19 | the definition of backwards compatible aliases that emit a deprecation warning 20 | when they are accessed. 21 | 22 | The way it works is that it wraps the original module in an :class:`DeprecationProxy` 23 | object that defines a :func:`~DeprecationProxy.__getattr__()` special method to 24 | override attribute access of the module. 25 | """ 26 | 27 | # Standard library modules. 28 | import collections 29 | import functools 30 | import importlib 31 | import inspect 32 | import sys 33 | import types 34 | import warnings 35 | 36 | # Modules included in our package. 37 | from humanfriendly.text import format 38 | 39 | # Registry of known aliases (used by humanfriendly.sphinx). 40 | REGISTRY = collections.defaultdict(dict) 41 | 42 | # Public identifiers that require documentation. 43 | __all__ = ("DeprecationProxy", "define_aliases", "deprecated_args", "get_aliases", "is_method") 44 | 45 | 46 | def define_aliases(module_name, **aliases): 47 | """ 48 | Update a module with backwards compatible aliases. 49 | 50 | :param module_name: The ``__name__`` of the module (a string). 51 | :param aliases: Each keyword argument defines an alias. The values 52 | are expected to be "dotted paths" (strings). 53 | 54 | The behavior of this function depends on whether the Sphinx documentation 55 | generator is active, because the use of :class:`DeprecationProxy` to shadow the 56 | real module in :data:`sys.modules` has the unintended side effect of 57 | breaking autodoc support for ``:data:`` members (module variables). 58 | 59 | To avoid breaking Sphinx the proxy object is omitted and instead the 60 | aliased names are injected into the original module namespace, to make sure 61 | that imports can be satisfied when the documentation is being rendered. 62 | 63 | If you run into cyclic dependencies caused by :func:`define_aliases()` when 64 | running Sphinx, you can try moving the call to :func:`define_aliases()` to 65 | the bottom of the Python module you're working on. 66 | """ 67 | module = sys.modules[module_name] 68 | proxy = DeprecationProxy(module, aliases) 69 | # Populate the registry of aliases. 70 | for name, target in aliases.items(): 71 | REGISTRY[module.__name__][name] = target 72 | # Avoid confusing Sphinx. 73 | if "sphinx" in sys.modules: 74 | for name, target in aliases.items(): 75 | setattr(module, name, proxy.resolve(target)) 76 | else: 77 | # Install a proxy object to raise DeprecationWarning. 78 | sys.modules[module_name] = proxy 79 | 80 | 81 | def get_aliases(module_name): 82 | """ 83 | Get the aliases defined by a module. 84 | 85 | :param module_name: The ``__name__`` of the module (a string). 86 | :returns: A dictionary with string keys and values: 87 | 88 | 1. Each key gives the name of an alias 89 | created for backwards compatibility. 90 | 91 | 2. Each value gives the dotted path of 92 | the proper location of the identifier. 93 | 94 | An empty dictionary is returned for modules that 95 | don't define any backwards compatible aliases. 96 | """ 97 | return REGISTRY.get(module_name, {}) 98 | 99 | 100 | def deprecated_args(*names): 101 | """ 102 | Deprecate positional arguments without dropping backwards compatibility. 103 | 104 | :param names: 105 | 106 | The positional arguments to :func:`deprecated_args()` give the names of 107 | the positional arguments that the to-be-decorated function should warn 108 | about being deprecated and translate to keyword arguments. 109 | 110 | :returns: A decorator function specialized to `names`. 111 | 112 | The :func:`deprecated_args()` decorator function was created to make it 113 | easy to switch from positional arguments to keyword arguments [#]_ while 114 | preserving backwards compatibility [#]_ and informing call sites 115 | about the change. 116 | 117 | .. [#] Increased flexibility is the main reason why I find myself switching 118 | from positional arguments to (optional) keyword arguments as my code 119 | evolves to support more use cases. 120 | 121 | .. [#] In my experience positional argument order implicitly becomes part 122 | of API compatibility whether intended or not. While this makes sense 123 | for functions that over time adopt more and more optional arguments, 124 | at a certain point it becomes an inconvenience to code maintenance. 125 | 126 | Here's an example of how to use the decorator:: 127 | 128 | @deprecated_args('text') 129 | def report_choice(**options): 130 | print(options['text']) 131 | 132 | When the decorated function is called with positional arguments 133 | a deprecation warning is given:: 134 | 135 | >>> report_choice('this will give a deprecation warning') 136 | DeprecationWarning: report_choice has deprecated positional arguments, please switch to keyword arguments 137 | this will give a deprecation warning 138 | 139 | But when the function is called with keyword arguments no deprecation 140 | warning is emitted:: 141 | 142 | >>> report_choice(text='this will not give a deprecation warning') 143 | this will not give a deprecation warning 144 | """ 145 | def decorator(function): 146 | def translate(args, kw): 147 | # Raise TypeError when too many positional arguments are passed to the decorated function. 148 | if len(args) > len(names): 149 | raise TypeError( 150 | format( 151 | "{name} expected at most {limit} arguments, got {count}", 152 | name=function.__name__, 153 | limit=len(names), 154 | count=len(args), 155 | ) 156 | ) 157 | # Emit a deprecation warning when positional arguments are used. 158 | if args: 159 | warnings.warn( 160 | format( 161 | "{name} has deprecated positional arguments, please switch to keyword arguments", 162 | name=function.__name__, 163 | ), 164 | category=DeprecationWarning, 165 | stacklevel=3, 166 | ) 167 | # Translate positional arguments to keyword arguments. 168 | for name, value in zip(names, args): 169 | kw[name] = value 170 | if is_method(function): 171 | @functools.wraps(function) 172 | def wrapper(*args, **kw): 173 | """Wrapper for instance methods.""" 174 | args = list(args) 175 | self = args.pop(0) 176 | translate(args, kw) 177 | return function(self, **kw) 178 | else: 179 | @functools.wraps(function) 180 | def wrapper(*args, **kw): 181 | """Wrapper for module level functions.""" 182 | translate(args, kw) 183 | return function(**kw) 184 | return wrapper 185 | return decorator 186 | 187 | 188 | def is_method(function): 189 | """Check if the expected usage of the given function is as an instance method.""" 190 | try: 191 | # Python 3.3 and newer. 192 | signature = inspect.signature(function) 193 | return "self" in signature.parameters 194 | except AttributeError: 195 | # Python 3.2 and older. 196 | metadata = inspect.getargspec(function) 197 | return "self" in metadata.args 198 | 199 | 200 | class DeprecationProxy(types.ModuleType): 201 | 202 | """Emit deprecation warnings for imports that should be updated.""" 203 | 204 | def __init__(self, module, aliases): 205 | """ 206 | Initialize an :class:`DeprecationProxy` object. 207 | 208 | :param module: The original module object. 209 | :param aliases: A dictionary of aliases. 210 | """ 211 | # Initialize our superclass. 212 | super(DeprecationProxy, self).__init__(name=module.__name__) 213 | # Store initializer arguments. 214 | self.module = module 215 | self.aliases = aliases 216 | 217 | def __getattr__(self, name): 218 | """ 219 | Override module attribute lookup. 220 | 221 | :param name: The name to look up (a string). 222 | :returns: The attribute value. 223 | """ 224 | # Check if the given name is an alias. 225 | target = self.aliases.get(name) 226 | if target is not None: 227 | # Emit the deprecation warning. 228 | warnings.warn( 229 | format("%s.%s was moved to %s, please update your imports", self.module.__name__, name, target), 230 | category=DeprecationWarning, 231 | stacklevel=2, 232 | ) 233 | # Resolve the dotted path. 234 | return self.resolve(target) 235 | # Look up the name in the original module namespace. 236 | value = getattr(self.module, name, None) 237 | if value is not None: 238 | return value 239 | # Fall back to the default behavior. 240 | raise AttributeError(format("module '%s' has no attribute '%s'", self.module.__name__, name)) 241 | 242 | def resolve(self, target): 243 | """ 244 | Look up the target of an alias. 245 | 246 | :param target: The fully qualified dotted path (a string). 247 | :returns: The value of the given target. 248 | """ 249 | module_name, _, member = target.rpartition(".") 250 | module = importlib.import_module(module_name) 251 | return getattr(module, member) 252 | -------------------------------------------------------------------------------- /humanfriendly/prompts.py: -------------------------------------------------------------------------------- 1 | # vim: fileencoding=utf-8 2 | 3 | # Human friendly input/output in Python. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: February 9, 2020 7 | # URL: https://humanfriendly.readthedocs.io 8 | 9 | """ 10 | Interactive terminal prompts. 11 | 12 | The :mod:`~humanfriendly.prompts` module enables interaction with the user 13 | (operator) by asking for confirmation (:func:`prompt_for_confirmation()`) and 14 | asking to choose from a list of options (:func:`prompt_for_choice()`). It works 15 | by rendering interactive prompts on the terminal. 16 | """ 17 | 18 | # Standard library modules. 19 | import logging 20 | import sys 21 | 22 | # Modules included in our package. 23 | from humanfriendly.compat import interactive_prompt 24 | from humanfriendly.terminal import ( 25 | HIGHLIGHT_COLOR, 26 | ansi_strip, 27 | ansi_wrap, 28 | connected_to_terminal, 29 | terminal_supports_colors, 30 | warning, 31 | ) 32 | from humanfriendly.text import format, concatenate 33 | 34 | # Public identifiers that require documentation. 35 | __all__ = ( 36 | 'MAX_ATTEMPTS', 37 | 'TooManyInvalidReplies', 38 | 'logger', 39 | 'prepare_friendly_prompts', 40 | 'prepare_prompt_text', 41 | 'prompt_for_choice', 42 | 'prompt_for_confirmation', 43 | 'prompt_for_input', 44 | 'retry_limit', 45 | ) 46 | 47 | MAX_ATTEMPTS = 10 48 | """The number of times an interactive prompt is shown on invalid input (an integer).""" 49 | 50 | # Initialize a logger for this module. 51 | logger = logging.getLogger(__name__) 52 | 53 | 54 | def prompt_for_confirmation(question, default=None, padding=True): 55 | """ 56 | Prompt the user for confirmation. 57 | 58 | :param question: The text that explains what the user is confirming (a string). 59 | :param default: The default value (a boolean) or :data:`None`. 60 | :param padding: Refer to the documentation of :func:`prompt_for_input()`. 61 | :returns: - If the user enters 'yes' or 'y' then :data:`True` is returned. 62 | - If the user enters 'no' or 'n' then :data:`False` is returned. 63 | - If the user doesn't enter any text or standard input is not 64 | connected to a terminal (which makes it impossible to prompt 65 | the user) the value of the keyword argument ``default`` is 66 | returned (if that value is not :data:`None`). 67 | :raises: - Any exceptions raised by :func:`retry_limit()`. 68 | - Any exceptions raised by :func:`prompt_for_input()`. 69 | 70 | When `default` is :data:`False` and the user doesn't enter any text an 71 | error message is printed and the prompt is repeated: 72 | 73 | >>> prompt_for_confirmation("Are you sure?") 74 | 75 | Are you sure? [y/n] 76 | 77 | Error: Please enter 'yes' or 'no' (there's no default choice). 78 | 79 | Are you sure? [y/n] 80 | 81 | The same thing happens when the user enters text that isn't recognized: 82 | 83 | >>> prompt_for_confirmation("Are you sure?") 84 | 85 | Are you sure? [y/n] about what? 86 | 87 | Error: Please enter 'yes' or 'no' (the text 'about what?' is not recognized). 88 | 89 | Are you sure? [y/n] 90 | """ 91 | # Generate the text for the prompt. 92 | prompt_text = prepare_prompt_text(question, bold=True) 93 | # Append the valid replies (and default reply) to the prompt text. 94 | hint = "[Y/n]" if default else "[y/N]" if default is not None else "[y/n]" 95 | prompt_text += " %s " % prepare_prompt_text(hint, color=HIGHLIGHT_COLOR) 96 | # Loop until a valid response is given. 97 | logger.debug("Requesting interactive confirmation from terminal: %r", ansi_strip(prompt_text).rstrip()) 98 | for attempt in retry_limit(): 99 | reply = prompt_for_input(prompt_text, '', padding=padding, strip=True) 100 | if reply.lower() in ('y', 'yes'): 101 | logger.debug("Confirmation granted by reply (%r).", reply) 102 | return True 103 | elif reply.lower() in ('n', 'no'): 104 | logger.debug("Confirmation denied by reply (%r).", reply) 105 | return False 106 | elif (not reply) and default is not None: 107 | logger.debug("Default choice selected by empty reply (%r).", 108 | "granted" if default else "denied") 109 | return default 110 | else: 111 | details = ("the text '%s' is not recognized" % reply 112 | if reply else "there's no default choice") 113 | logger.debug("Got %s reply (%s), retrying (%i/%i) ..", 114 | "invalid" if reply else "empty", details, 115 | attempt, MAX_ATTEMPTS) 116 | warning("{indent}Error: Please enter 'yes' or 'no' ({details}).", 117 | indent=' ' if padding else '', details=details) 118 | 119 | 120 | def prompt_for_choice(choices, default=None, padding=True): 121 | """ 122 | Prompt the user to select a choice from a group of options. 123 | 124 | :param choices: A sequence of strings with available options. 125 | :param default: The default choice if the user simply presses Enter 126 | (expected to be a string, defaults to :data:`None`). 127 | :param padding: Refer to the documentation of 128 | :func:`~humanfriendly.prompts.prompt_for_input()`. 129 | :returns: The string corresponding to the user's choice. 130 | :raises: - :exc:`~exceptions.ValueError` if `choices` is an empty sequence. 131 | - Any exceptions raised by 132 | :func:`~humanfriendly.prompts.retry_limit()`. 133 | - Any exceptions raised by 134 | :func:`~humanfriendly.prompts.prompt_for_input()`. 135 | 136 | When no options are given an exception is raised: 137 | 138 | >>> prompt_for_choice([]) 139 | Traceback (most recent call last): 140 | File "humanfriendly/prompts.py", line 148, in prompt_for_choice 141 | raise ValueError("Can't prompt for choice without any options!") 142 | ValueError: Can't prompt for choice without any options! 143 | 144 | If a single option is given the user isn't prompted: 145 | 146 | >>> prompt_for_choice(['only one choice']) 147 | 'only one choice' 148 | 149 | Here's what the actual prompt looks like by default: 150 | 151 | >>> prompt_for_choice(['first option', 'second option']) 152 | 153 | 1. first option 154 | 2. second option 155 | 156 | Enter your choice as a number or unique substring (Control-C aborts): second 157 | 158 | 'second option' 159 | 160 | If you don't like the whitespace (empty lines and indentation): 161 | 162 | >>> prompt_for_choice(['first option', 'second option'], padding=False) 163 | 1. first option 164 | 2. second option 165 | Enter your choice as a number or unique substring (Control-C aborts): first 166 | 'first option' 167 | """ 168 | indent = ' ' if padding else '' 169 | # Make sure we can use 'choices' more than once (i.e. not a generator). 170 | choices = list(choices) 171 | if len(choices) == 1: 172 | # If there's only one option there's no point in prompting the user. 173 | logger.debug("Skipping interactive prompt because there's only option (%r).", choices[0]) 174 | return choices[0] 175 | elif not choices: 176 | # We can't render a choice prompt without any options. 177 | raise ValueError("Can't prompt for choice without any options!") 178 | # Generate the prompt text. 179 | prompt_text = ('\n\n' if padding else '\n').join([ 180 | # Present the available choices in a user friendly way. 181 | "\n".join([ 182 | (u" %i. %s" % (i, choice)) + (" (default choice)" if choice == default else "") 183 | for i, choice in enumerate(choices, start=1) 184 | ]), 185 | # Instructions for the user. 186 | "Enter your choice as a number or unique substring (Control-C aborts): ", 187 | ]) 188 | prompt_text = prepare_prompt_text(prompt_text, bold=True) 189 | # Loop until a valid choice is made. 190 | logger.debug("Requesting interactive choice on terminal (options are %s) ..", 191 | concatenate(map(repr, choices))) 192 | for attempt in retry_limit(): 193 | reply = prompt_for_input(prompt_text, '', padding=padding, strip=True) 194 | if not reply and default is not None: 195 | logger.debug("Default choice selected by empty reply (%r).", default) 196 | return default 197 | elif reply.isdigit(): 198 | index = int(reply) - 1 199 | if 0 <= index < len(choices): 200 | logger.debug("Option (%r) selected by numeric reply (%s).", choices[index], reply) 201 | return choices[index] 202 | # Check for substring matches. 203 | matches = [] 204 | for choice in choices: 205 | lower_reply = reply.lower() 206 | lower_choice = choice.lower() 207 | if lower_reply == lower_choice: 208 | # If we have an 'exact' match we return it immediately. 209 | logger.debug("Option (%r) selected by reply (exact match).", choice) 210 | return choice 211 | elif lower_reply in lower_choice and len(lower_reply) > 0: 212 | # Otherwise we gather substring matches. 213 | matches.append(choice) 214 | if len(matches) == 1: 215 | # If a single choice was matched we return it. 216 | logger.debug("Option (%r) selected by reply (substring match on %r).", matches[0], reply) 217 | return matches[0] 218 | else: 219 | # Give the user a hint about what went wrong. 220 | if matches: 221 | details = format("text '%s' matches more than one choice: %s", reply, concatenate(matches)) 222 | elif reply.isdigit(): 223 | details = format("number %i is not a valid choice", int(reply)) 224 | elif reply and not reply.isspace(): 225 | details = format("text '%s' doesn't match any choices", reply) 226 | else: 227 | details = "there's no default choice" 228 | logger.debug("Got %s reply (%s), retrying (%i/%i) ..", 229 | "invalid" if reply else "empty", details, 230 | attempt, MAX_ATTEMPTS) 231 | warning("%sError: Invalid input (%s).", indent, details) 232 | 233 | 234 | def prompt_for_input(question, default=None, padding=True, strip=True): 235 | """ 236 | Prompt the user for input (free form text). 237 | 238 | :param question: An explanation of what is expected from the user (a string). 239 | :param default: The return value if the user doesn't enter any text or 240 | standard input is not connected to a terminal (which 241 | makes it impossible to prompt the user). 242 | :param padding: Render empty lines before and after the prompt to make it 243 | stand out from the surrounding text? (a boolean, defaults 244 | to :data:`True`) 245 | :param strip: Strip leading/trailing whitespace from the user's reply? 246 | :returns: The text entered by the user (a string) or the value of the 247 | `default` argument. 248 | :raises: - :exc:`~exceptions.KeyboardInterrupt` when the program is 249 | interrupted_ while the prompt is active, for example 250 | because the user presses Control-C_. 251 | - :exc:`~exceptions.EOFError` when reading from `standard input`_ 252 | fails, for example because the user presses Control-D_ or 253 | because the standard input stream is redirected (only if 254 | `default` is :data:`None`). 255 | 256 | .. _Control-C: https://en.wikipedia.org/wiki/Control-C#In_command-line_environments 257 | .. _Control-D: https://en.wikipedia.org/wiki/End-of-transmission_character#Meaning_in_Unix 258 | .. _interrupted: https://en.wikipedia.org/wiki/Unix_signal#SIGINT 259 | .. _standard input: https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29 260 | """ 261 | prepare_friendly_prompts() 262 | reply = None 263 | try: 264 | # Prefix an empty line to the text and indent by one space? 265 | if padding: 266 | question = '\n' + question 267 | question = question.replace('\n', '\n ') 268 | # Render the prompt and wait for the user's reply. 269 | try: 270 | reply = interactive_prompt(question) 271 | finally: 272 | if reply is None: 273 | # If the user terminated the prompt using Control-C or 274 | # Control-D instead of pressing Enter no newline will be 275 | # rendered after the prompt's text. The result looks kind of 276 | # weird: 277 | # 278 | # $ python -c 'print(raw_input("Are you sure? "))' 279 | # Are you sure? ^CTraceback (most recent call last): 280 | # File "", line 1, in 281 | # KeyboardInterrupt 282 | # 283 | # We can avoid this by emitting a newline ourselves if an 284 | # exception was raised (signaled by `reply' being None). 285 | sys.stderr.write('\n') 286 | if padding: 287 | # If the caller requested (didn't opt out of) `padding' then we'll 288 | # emit a newline regardless of whether an exception is being 289 | # handled. This helps to make interactive prompts `stand out' from 290 | # a surrounding `wall of text' on the terminal. 291 | sys.stderr.write('\n') 292 | except BaseException as e: 293 | if isinstance(e, EOFError) and default is not None: 294 | # If standard input isn't connected to an interactive terminal 295 | # but the caller provided a default we'll return that. 296 | logger.debug("Got EOF from terminal, returning default value (%r) ..", default) 297 | return default 298 | else: 299 | # Otherwise we log that the prompt was interrupted but propagate 300 | # the exception to the caller. 301 | logger.warning("Interactive prompt was interrupted by exception!", exc_info=True) 302 | raise 303 | if default is not None and not reply: 304 | # If the reply is empty and `default' is None we don't want to return 305 | # None because it's nicer for callers to be able to assume that the 306 | # return value is always a string. 307 | return default 308 | else: 309 | return reply.strip() 310 | 311 | 312 | def prepare_prompt_text(prompt_text, **options): 313 | """ 314 | Wrap a text to be rendered as an interactive prompt in ANSI escape sequences. 315 | 316 | :param prompt_text: The text to render on the prompt (a string). 317 | :param options: Any keyword arguments are passed on to :func:`.ansi_wrap()`. 318 | :returns: The resulting prompt text (a string). 319 | 320 | ANSI escape sequences are only used when the standard output stream is 321 | connected to a terminal. When the standard input stream is connected to a 322 | terminal any escape sequences are wrapped in "readline hints". 323 | """ 324 | return (ansi_wrap(prompt_text, readline_hints=connected_to_terminal(sys.stdin), **options) 325 | if terminal_supports_colors(sys.stdout) 326 | else prompt_text) 327 | 328 | 329 | def prepare_friendly_prompts(): 330 | u""" 331 | Make interactive prompts more user friendly. 332 | 333 | The prompts presented by :func:`python2:raw_input()` (in Python 2) and 334 | :func:`python3:input()` (in Python 3) are not very user friendly by 335 | default, for example the cursor keys (:kbd:`←`, :kbd:`↑`, :kbd:`→` and 336 | :kbd:`↓`) and the :kbd:`Home` and :kbd:`End` keys enter characters instead 337 | of performing the action you would expect them to. By simply importing the 338 | :mod:`readline` module these prompts become much friendlier (as mentioned 339 | in the Python standard library documentation). 340 | 341 | This function is called by the other functions in this module to enable 342 | user friendly prompts. 343 | """ 344 | try: 345 | import readline # NOQA 346 | except ImportError: 347 | # might not be available on Windows if pyreadline isn't installed 348 | pass 349 | 350 | 351 | def retry_limit(limit=MAX_ATTEMPTS): 352 | """ 353 | Allow the user to provide valid input up to `limit` times. 354 | 355 | :param limit: The maximum number of attempts (a number, 356 | defaults to :data:`MAX_ATTEMPTS`). 357 | :returns: A generator of numbers starting from one. 358 | :raises: :exc:`TooManyInvalidReplies` when an interactive prompt 359 | receives repeated invalid input (:data:`MAX_ATTEMPTS`). 360 | 361 | This function returns a generator for interactive prompts that want to 362 | repeat on invalid input without getting stuck in infinite loops. 363 | """ 364 | for i in range(limit): 365 | yield i + 1 366 | msg = "Received too many invalid replies on interactive prompt, giving up! (tried %i times)" 367 | formatted_msg = msg % limit 368 | # Make sure the event is logged. 369 | logger.warning(formatted_msg) 370 | # Force the caller to decide what to do now. 371 | raise TooManyInvalidReplies(formatted_msg) 372 | 373 | 374 | class TooManyInvalidReplies(Exception): 375 | 376 | """Raised by interactive prompts when they've received too many invalid inputs.""" 377 | -------------------------------------------------------------------------------- /humanfriendly/sphinx.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: June 11, 2021 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Customizations for and integration with the Sphinx_ documentation generator. 9 | 10 | The :mod:`humanfriendly.sphinx` module uses the `Sphinx extension API`_ to 11 | customize the process of generating Sphinx based Python documentation. To 12 | explore the functionality this module offers its best to start reading 13 | from the :func:`setup()` function. 14 | 15 | .. _Sphinx: http://www.sphinx-doc.org/ 16 | .. _Sphinx extension API: http://sphinx-doc.org/extdev/appapi.html 17 | """ 18 | 19 | # Standard library modules. 20 | import logging 21 | import types 22 | 23 | # External dependencies (if Sphinx is installed docutils will be installed). 24 | import docutils.nodes 25 | import docutils.utils 26 | 27 | # Modules included in our package. 28 | from humanfriendly.deprecation import get_aliases 29 | from humanfriendly.text import compact, dedent, format 30 | from humanfriendly.usage import USAGE_MARKER, render_usage 31 | 32 | # Public identifiers that require documentation. 33 | __all__ = ( 34 | "deprecation_note_callback", 35 | "enable_deprecation_notes", 36 | "enable_man_role", 37 | "enable_pypi_role", 38 | "enable_special_methods", 39 | "enable_usage_formatting", 40 | "logger", 41 | "man_role", 42 | "pypi_role", 43 | "setup", 44 | "special_methods_callback", 45 | "usage_message_callback", 46 | ) 47 | 48 | # Initialize a logger for this module. 49 | logger = logging.getLogger(__name__) 50 | 51 | 52 | def deprecation_note_callback(app, what, name, obj, options, lines): 53 | """ 54 | Automatically document aliases defined using :func:`~humanfriendly.deprecation.define_aliases()`. 55 | 56 | Refer to :func:`enable_deprecation_notes()` to enable the use of this 57 | function (you probably don't want to call :func:`deprecation_note_callback()` 58 | directly). 59 | 60 | This function implements a callback for ``autodoc-process-docstring`` that 61 | reformats module docstrings to append an overview of aliases defined by the 62 | module. 63 | 64 | The parameters expected by this function are those defined for Sphinx event 65 | callback functions (i.e. I'm not going to document them here :-). 66 | """ 67 | if isinstance(obj, types.ModuleType) and lines: 68 | aliases = get_aliases(obj.__name__) 69 | if aliases: 70 | # Convert the existing docstring to a string and remove leading 71 | # indentation from that string, otherwise our generated content 72 | # would have to match the existing indentation in order not to 73 | # break docstring parsing (because indentation is significant 74 | # in the reStructuredText format). 75 | blocks = [dedent("\n".join(lines))] 76 | # Use an admonition to group the deprecated aliases together and 77 | # to distinguish them from the autodoc entries that follow. 78 | blocks.append(".. note:: Deprecated names") 79 | indent = " " * 3 80 | if len(aliases) == 1: 81 | explanation = """ 82 | The following alias exists to preserve backwards compatibility, 83 | however a :exc:`~exceptions.DeprecationWarning` is triggered 84 | when it is accessed, because this alias will be removed 85 | in a future release. 86 | """ 87 | else: 88 | explanation = """ 89 | The following aliases exist to preserve backwards compatibility, 90 | however a :exc:`~exceptions.DeprecationWarning` is triggered 91 | when they are accessed, because these aliases will be 92 | removed in a future release. 93 | """ 94 | blocks.append(indent + compact(explanation)) 95 | for name, target in aliases.items(): 96 | blocks.append(format("%s.. data:: %s", indent, name)) 97 | blocks.append(format("%sAlias for :obj:`%s`.", indent * 2, target)) 98 | update_lines(lines, "\n\n".join(blocks)) 99 | 100 | 101 | def enable_deprecation_notes(app): 102 | """ 103 | Enable documenting backwards compatibility aliases using the autodoc_ extension. 104 | 105 | :param app: The Sphinx application object. 106 | 107 | This function connects the :func:`deprecation_note_callback()` function to 108 | ``autodoc-process-docstring`` events. 109 | 110 | .. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html 111 | """ 112 | app.connect("autodoc-process-docstring", deprecation_note_callback) 113 | 114 | 115 | def enable_man_role(app): 116 | """ 117 | Enable the ``:man:`` role for linking to Debian Linux manual pages. 118 | 119 | :param app: The Sphinx application object. 120 | 121 | This function registers the :func:`man_role()` function to handle the 122 | ``:man:`` role. 123 | """ 124 | app.add_role("man", man_role) 125 | 126 | 127 | def enable_pypi_role(app): 128 | """ 129 | Enable the ``:pypi:`` role for linking to the Python Package Index. 130 | 131 | :param app: The Sphinx application object. 132 | 133 | This function registers the :func:`pypi_role()` function to handle the 134 | ``:pypi:`` role. 135 | """ 136 | app.add_role("pypi", pypi_role) 137 | 138 | 139 | def enable_special_methods(app): 140 | """ 141 | Enable documenting "special methods" using the autodoc_ extension. 142 | 143 | :param app: The Sphinx application object. 144 | 145 | This function connects the :func:`special_methods_callback()` function to 146 | ``autodoc-skip-member`` events. 147 | 148 | .. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html 149 | """ 150 | app.connect("autodoc-skip-member", special_methods_callback) 151 | 152 | 153 | def enable_usage_formatting(app): 154 | """ 155 | Reformat human friendly usage messages to reStructuredText_. 156 | 157 | :param app: The Sphinx application object (as given to ``setup()``). 158 | 159 | This function connects the :func:`usage_message_callback()` function to 160 | ``autodoc-process-docstring`` events. 161 | 162 | .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText 163 | """ 164 | app.connect("autodoc-process-docstring", usage_message_callback) 165 | 166 | 167 | def man_role(role, rawtext, text, lineno, inliner, options={}, content=[]): 168 | """ 169 | Convert a Linux manual topic to a hyperlink. 170 | 171 | Using the ``:man:`` role is very simple, here's an example: 172 | 173 | .. code-block:: rst 174 | 175 | See the :man:`python` documentation. 176 | 177 | This results in the following: 178 | 179 | See the :man:`python` documentation. 180 | 181 | As the example shows you can use the role inline, embedded in sentences of 182 | text. In the generated documentation the ``:man:`` text is omitted and a 183 | hyperlink pointing to the Debian Linux manual pages is emitted. 184 | """ 185 | man_url = "https://manpages.debian.org/%s" % text 186 | reference = docutils.nodes.reference(rawtext, docutils.utils.unescape(text), refuri=man_url, **options) 187 | return [reference], [] 188 | 189 | 190 | def pypi_role(role, rawtext, text, lineno, inliner, options={}, content=[]): 191 | """ 192 | Generate hyperlinks to the Python Package Index. 193 | 194 | Using the ``:pypi:`` role is very simple, here's an example: 195 | 196 | .. code-block:: rst 197 | 198 | See the :pypi:`humanfriendly` package. 199 | 200 | This results in the following: 201 | 202 | See the :pypi:`humanfriendly` package. 203 | 204 | As the example shows you can use the role inline, embedded in sentences of 205 | text. In the generated documentation the ``:pypi:`` text is omitted and a 206 | hyperlink pointing to the Python Package Index is emitted. 207 | """ 208 | pypi_url = "https://pypi.org/project/%s/" % text 209 | reference = docutils.nodes.reference(rawtext, docutils.utils.unescape(text), refuri=pypi_url, **options) 210 | return [reference], [] 211 | 212 | 213 | def setup(app): 214 | """ 215 | Enable all of the provided Sphinx_ customizations. 216 | 217 | :param app: The Sphinx application object. 218 | 219 | The :func:`setup()` function makes it easy to enable all of the Sphinx 220 | customizations provided by the :mod:`humanfriendly.sphinx` module with the 221 | least amount of code. All you need to do is to add the module name to the 222 | ``extensions`` variable in your ``conf.py`` file: 223 | 224 | .. code-block:: python 225 | 226 | # Sphinx extension module names. 227 | extensions = [ 228 | 'sphinx.ext.autodoc', 229 | 'sphinx.ext.doctest', 230 | 'sphinx.ext.intersphinx', 231 | 'humanfriendly.sphinx', 232 | ] 233 | 234 | When Sphinx sees the :mod:`humanfriendly.sphinx` name it will import the 235 | module and call its :func:`setup()` function. This function will then call 236 | the following: 237 | 238 | - :func:`enable_deprecation_notes()` 239 | - :func:`enable_man_role()` 240 | - :func:`enable_pypi_role()` 241 | - :func:`enable_special_methods()` 242 | - :func:`enable_usage_formatting()` 243 | 244 | Of course more functionality may be added at a later stage. If you don't 245 | like that idea you may be better of calling the individual functions from 246 | your own ``setup()`` function. 247 | """ 248 | from humanfriendly import __version__ 249 | 250 | enable_deprecation_notes(app) 251 | enable_man_role(app) 252 | enable_pypi_role(app) 253 | enable_special_methods(app) 254 | enable_usage_formatting(app) 255 | 256 | return dict(parallel_read_safe=True, parallel_write_safe=True, version=__version__) 257 | 258 | 259 | def special_methods_callback(app, what, name, obj, skip, options): 260 | """ 261 | Enable documenting "special methods" using the autodoc_ extension. 262 | 263 | Refer to :func:`enable_special_methods()` to enable the use of this 264 | function (you probably don't want to call 265 | :func:`special_methods_callback()` directly). 266 | 267 | This function implements a callback for ``autodoc-skip-member`` events to 268 | include documented "special methods" (method names with two leading and two 269 | trailing underscores) in your documentation. The result is similar to the 270 | use of the ``special-members`` flag with one big difference: Special 271 | methods are included but other types of members are ignored. This means 272 | that attributes like ``__weakref__`` will always be ignored (this was my 273 | main annoyance with the ``special-members`` flag). 274 | 275 | The parameters expected by this function are those defined for Sphinx event 276 | callback functions (i.e. I'm not going to document them here :-). 277 | """ 278 | if getattr(obj, "__doc__", None) and isinstance(obj, (types.FunctionType, types.MethodType)): 279 | return False 280 | else: 281 | return skip 282 | 283 | 284 | def update_lines(lines, text): 285 | """Private helper for ``autodoc-process-docstring`` callbacks.""" 286 | while lines: 287 | lines.pop() 288 | lines.extend(text.splitlines()) 289 | 290 | 291 | def usage_message_callback(app, what, name, obj, options, lines): 292 | """ 293 | Reformat human friendly usage messages to reStructuredText_. 294 | 295 | Refer to :func:`enable_usage_formatting()` to enable the use of this 296 | function (you probably don't want to call :func:`usage_message_callback()` 297 | directly). 298 | 299 | This function implements a callback for ``autodoc-process-docstring`` that 300 | reformats module docstrings using :func:`.render_usage()` so that Sphinx 301 | doesn't mangle usage messages that were written to be human readable 302 | instead of machine readable. Only module docstrings whose first line starts 303 | with :data:`.USAGE_MARKER` are reformatted. 304 | 305 | The parameters expected by this function are those defined for Sphinx event 306 | callback functions (i.e. I'm not going to document them here :-). 307 | """ 308 | # Make sure we only modify the docstrings of modules. 309 | if isinstance(obj, types.ModuleType) and lines: 310 | # Make sure we only modify docstrings containing a usage message. 311 | if lines[0].startswith(USAGE_MARKER): 312 | # Convert the usage message to reStructuredText. 313 | text = render_usage("\n".join(lines)) 314 | # Fill up the buffer with our modified docstring. 315 | update_lines(lines, text) 316 | -------------------------------------------------------------------------------- /humanfriendly/tables.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: February 16, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Functions that render ASCII tables. 9 | 10 | Some generic notes about the table formatting functions in this module: 11 | 12 | - These functions were not written with performance in mind (*at all*) because 13 | they're intended to format tabular data to be presented on a terminal. If 14 | someone were to run into a performance problem using these functions, they'd 15 | be printing so much tabular data to the terminal that a human wouldn't be 16 | able to digest the tabular data anyway, so the point is moot :-). 17 | 18 | - These functions ignore ANSI escape sequences (at least the ones generated by 19 | the :mod:`~humanfriendly.terminal` module) in the calculation of columns 20 | widths. On reason for this is that column names are highlighted in color when 21 | connected to a terminal. It also means that you can use ANSI escape sequences 22 | to highlight certain column's values if you feel like it (for example to 23 | highlight deviations from the norm in an overview of calculated values). 24 | """ 25 | 26 | # Standard library modules. 27 | import collections 28 | import re 29 | 30 | # Modules included in our package. 31 | from humanfriendly.compat import coerce_string 32 | from humanfriendly.terminal import ( 33 | ansi_strip, 34 | ansi_width, 35 | ansi_wrap, 36 | terminal_supports_colors, 37 | find_terminal_size, 38 | HIGHLIGHT_COLOR, 39 | ) 40 | 41 | # Public identifiers that require documentation. 42 | __all__ = ( 43 | 'format_pretty_table', 44 | 'format_robust_table', 45 | 'format_rst_table', 46 | 'format_smart_table', 47 | ) 48 | 49 | # Compiled regular expression pattern to recognize table columns containing 50 | # numeric data (integer and/or floating point numbers). Used to right-align the 51 | # contents of such columns. 52 | # 53 | # Pre-emptive snarky comment: This pattern doesn't match every possible 54 | # floating point number notation!?!1!1 55 | # 56 | # Response: I know, that's intentional. The use of this regular expression 57 | # pattern has a very high DWIM level and weird floating point notations do not 58 | # fall under the DWIM umbrella :-). 59 | NUMERIC_DATA_PATTERN = re.compile(r'^\d+(\.\d+)?$') 60 | 61 | 62 | def format_smart_table(data, column_names): 63 | """ 64 | Render tabular data using the most appropriate representation. 65 | 66 | :param data: An iterable (e.g. a :func:`tuple` or :class:`list`) 67 | containing the rows of the table, where each row is an 68 | iterable containing the columns of the table (strings). 69 | :param column_names: An iterable of column names (strings). 70 | :returns: The rendered table (a string). 71 | 72 | If you want an easy way to render tabular data on a terminal in a human 73 | friendly format then this function is for you! It works as follows: 74 | 75 | - If the input data doesn't contain any line breaks the function 76 | :func:`format_pretty_table()` is used to render a pretty table. If the 77 | resulting table fits in the terminal without wrapping the rendered pretty 78 | table is returned. 79 | 80 | - If the input data does contain line breaks or if a pretty table would 81 | wrap (given the width of the terminal) then the function 82 | :func:`format_robust_table()` is used to render a more robust table that 83 | can deal with data containing line breaks and long text. 84 | """ 85 | # Normalize the input in case we fall back from a pretty table to a robust 86 | # table (in which case we'll definitely iterate the input more than once). 87 | data = [normalize_columns(r) for r in data] 88 | column_names = normalize_columns(column_names) 89 | # Make sure the input data doesn't contain any line breaks (because pretty 90 | # tables break horribly when a column's text contains a line break :-). 91 | if not any(any('\n' in c for c in r) for r in data): 92 | # Render a pretty table. 93 | pretty_table = format_pretty_table(data, column_names) 94 | # Check if the pretty table fits in the terminal. 95 | table_width = max(map(ansi_width, pretty_table.splitlines())) 96 | num_rows, num_columns = find_terminal_size() 97 | if table_width <= num_columns: 98 | # The pretty table fits in the terminal without wrapping! 99 | return pretty_table 100 | # Fall back to a robust table when a pretty table won't work. 101 | return format_robust_table(data, column_names) 102 | 103 | 104 | def format_pretty_table(data, column_names=None, horizontal_bar='-', vertical_bar='|'): 105 | """ 106 | Render a table using characters like dashes and vertical bars to emulate borders. 107 | 108 | :param data: An iterable (e.g. a :func:`tuple` or :class:`list`) 109 | containing the rows of the table, where each row is an 110 | iterable containing the columns of the table (strings). 111 | :param column_names: An iterable of column names (strings). 112 | :param horizontal_bar: The character used to represent a horizontal bar (a 113 | string). 114 | :param vertical_bar: The character used to represent a vertical bar (a 115 | string). 116 | :returns: The rendered table (a string). 117 | 118 | Here's an example: 119 | 120 | >>> from humanfriendly.tables import format_pretty_table 121 | >>> column_names = ['Version', 'Uploaded on', 'Downloads'] 122 | >>> humanfriendly_releases = [ 123 | ... ['1.23', '2015-05-25', '218'], 124 | ... ['1.23.1', '2015-05-26', '1354'], 125 | ... ['1.24', '2015-05-26', '223'], 126 | ... ['1.25', '2015-05-26', '4319'], 127 | ... ['1.25.1', '2015-06-02', '197'], 128 | ... ] 129 | >>> print(format_pretty_table(humanfriendly_releases, column_names)) 130 | ------------------------------------- 131 | | Version | Uploaded on | Downloads | 132 | ------------------------------------- 133 | | 1.23 | 2015-05-25 | 218 | 134 | | 1.23.1 | 2015-05-26 | 1354 | 135 | | 1.24 | 2015-05-26 | 223 | 136 | | 1.25 | 2015-05-26 | 4319 | 137 | | 1.25.1 | 2015-06-02 | 197 | 138 | ------------------------------------- 139 | 140 | Notes about the resulting table: 141 | 142 | - If a column contains numeric data (integer and/or floating point 143 | numbers) in all rows (ignoring column names of course) then the content 144 | of that column is right-aligned, as can be seen in the example above. The 145 | idea here is to make it easier to compare the numbers in different 146 | columns to each other. 147 | 148 | - The column names are highlighted in color so they stand out a bit more 149 | (see also :data:`.HIGHLIGHT_COLOR`). The following screen shot shows what 150 | that looks like (my terminals are always set to white text on a black 151 | background): 152 | 153 | .. image:: images/pretty-table.png 154 | """ 155 | # Normalize the input because we'll have to iterate it more than once. 156 | data = [normalize_columns(r, expandtabs=True) for r in data] 157 | if column_names is not None: 158 | column_names = normalize_columns(column_names) 159 | if column_names: 160 | if terminal_supports_colors(): 161 | column_names = [highlight_column_name(n) for n in column_names] 162 | data.insert(0, column_names) 163 | # Calculate the maximum width of each column. 164 | widths = collections.defaultdict(int) 165 | numeric_data = collections.defaultdict(list) 166 | for row_index, row in enumerate(data): 167 | for column_index, column in enumerate(row): 168 | widths[column_index] = max(widths[column_index], ansi_width(column)) 169 | if not (column_names and row_index == 0): 170 | numeric_data[column_index].append(bool(NUMERIC_DATA_PATTERN.match(ansi_strip(column)))) 171 | # Create a horizontal bar of dashes as a delimiter. 172 | line_delimiter = horizontal_bar * (sum(widths.values()) + len(widths) * 3 + 1) 173 | # Start the table with a vertical bar. 174 | lines = [line_delimiter] 175 | # Format the rows and columns. 176 | for row_index, row in enumerate(data): 177 | line = [vertical_bar] 178 | for column_index, column in enumerate(row): 179 | padding = ' ' * (widths[column_index] - ansi_width(column)) 180 | if all(numeric_data[column_index]): 181 | line.append(' ' + padding + column + ' ') 182 | else: 183 | line.append(' ' + column + padding + ' ') 184 | line.append(vertical_bar) 185 | lines.append(u''.join(line)) 186 | if column_names and row_index == 0: 187 | lines.append(line_delimiter) 188 | # End the table with a vertical bar. 189 | lines.append(line_delimiter) 190 | # Join the lines, returning a single string. 191 | return u'\n'.join(lines) 192 | 193 | 194 | def format_robust_table(data, column_names): 195 | """ 196 | Render tabular data with one column per line (allowing columns with line breaks). 197 | 198 | :param data: An iterable (e.g. a :func:`tuple` or :class:`list`) 199 | containing the rows of the table, where each row is an 200 | iterable containing the columns of the table (strings). 201 | :param column_names: An iterable of column names (strings). 202 | :returns: The rendered table (a string). 203 | 204 | Here's an example: 205 | 206 | >>> from humanfriendly.tables import format_robust_table 207 | >>> column_names = ['Version', 'Uploaded on', 'Downloads'] 208 | >>> humanfriendly_releases = [ 209 | ... ['1.23', '2015-05-25', '218'], 210 | ... ['1.23.1', '2015-05-26', '1354'], 211 | ... ['1.24', '2015-05-26', '223'], 212 | ... ['1.25', '2015-05-26', '4319'], 213 | ... ['1.25.1', '2015-06-02', '197'], 214 | ... ] 215 | >>> print(format_robust_table(humanfriendly_releases, column_names)) 216 | ----------------------- 217 | Version: 1.23 218 | Uploaded on: 2015-05-25 219 | Downloads: 218 220 | ----------------------- 221 | Version: 1.23.1 222 | Uploaded on: 2015-05-26 223 | Downloads: 1354 224 | ----------------------- 225 | Version: 1.24 226 | Uploaded on: 2015-05-26 227 | Downloads: 223 228 | ----------------------- 229 | Version: 1.25 230 | Uploaded on: 2015-05-26 231 | Downloads: 4319 232 | ----------------------- 233 | Version: 1.25.1 234 | Uploaded on: 2015-06-02 235 | Downloads: 197 236 | ----------------------- 237 | 238 | The column names are highlighted in bold font and color so they stand out a 239 | bit more (see :data:`.HIGHLIGHT_COLOR`). 240 | """ 241 | blocks = [] 242 | column_names = ["%s:" % n for n in normalize_columns(column_names)] 243 | if terminal_supports_colors(): 244 | column_names = [highlight_column_name(n) for n in column_names] 245 | # Convert each row into one or more `name: value' lines (one per column) 246 | # and group each `row of lines' into a block (i.e. rows become blocks). 247 | for row in data: 248 | lines = [] 249 | for column_index, column_text in enumerate(normalize_columns(row)): 250 | stripped_column = column_text.strip() 251 | if '\n' not in stripped_column: 252 | # Columns without line breaks are formatted inline. 253 | lines.append("%s %s" % (column_names[column_index], stripped_column)) 254 | else: 255 | # Columns with line breaks could very well contain indented 256 | # lines, so we'll put the column name on a separate line. This 257 | # way any indentation remains intact, and it's easier to 258 | # copy/paste the text. 259 | lines.append(column_names[column_index]) 260 | lines.extend(column_text.rstrip().splitlines()) 261 | blocks.append(lines) 262 | # Calculate the width of the row delimiter. 263 | num_rows, num_columns = find_terminal_size() 264 | longest_line = max(max(map(ansi_width, lines)) for lines in blocks) 265 | delimiter = u"\n%s\n" % ('-' * min(longest_line, num_columns)) 266 | # Force a delimiter at the start and end of the table. 267 | blocks.insert(0, "") 268 | blocks.append("") 269 | # Embed the row delimiter between every two blocks. 270 | return delimiter.join(u"\n".join(b) for b in blocks).strip() 271 | 272 | 273 | def format_rst_table(data, column_names=None): 274 | """ 275 | Render a table in reStructuredText_ format. 276 | 277 | :param data: An iterable (e.g. a :func:`tuple` or :class:`list`) 278 | containing the rows of the table, where each row is an 279 | iterable containing the columns of the table (strings). 280 | :param column_names: An iterable of column names (strings). 281 | :returns: The rendered table (a string). 282 | 283 | Here's an example: 284 | 285 | >>> from humanfriendly.tables import format_rst_table 286 | >>> column_names = ['Version', 'Uploaded on', 'Downloads'] 287 | >>> humanfriendly_releases = [ 288 | ... ['1.23', '2015-05-25', '218'], 289 | ... ['1.23.1', '2015-05-26', '1354'], 290 | ... ['1.24', '2015-05-26', '223'], 291 | ... ['1.25', '2015-05-26', '4319'], 292 | ... ['1.25.1', '2015-06-02', '197'], 293 | ... ] 294 | >>> print(format_rst_table(humanfriendly_releases, column_names)) 295 | ======= =========== ========= 296 | Version Uploaded on Downloads 297 | ======= =========== ========= 298 | 1.23 2015-05-25 218 299 | 1.23.1 2015-05-26 1354 300 | 1.24 2015-05-26 223 301 | 1.25 2015-05-26 4319 302 | 1.25.1 2015-06-02 197 303 | ======= =========== ========= 304 | 305 | .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText 306 | """ 307 | data = [normalize_columns(r) for r in data] 308 | if column_names: 309 | data.insert(0, normalize_columns(column_names)) 310 | # Calculate the maximum width of each column. 311 | widths = collections.defaultdict(int) 312 | for row in data: 313 | for index, column in enumerate(row): 314 | widths[index] = max(widths[index], len(column)) 315 | # Pad the columns using whitespace. 316 | for row in data: 317 | for index, column in enumerate(row): 318 | if index < (len(row) - 1): 319 | row[index] = column.ljust(widths[index]) 320 | # Add table markers. 321 | delimiter = ['=' * w for i, w in sorted(widths.items())] 322 | if column_names: 323 | data.insert(1, delimiter) 324 | data.insert(0, delimiter) 325 | data.append(delimiter) 326 | # Join the lines and columns together. 327 | return '\n'.join(' '.join(r) for r in data) 328 | 329 | 330 | def normalize_columns(row, expandtabs=False): 331 | results = [] 332 | for value in row: 333 | text = coerce_string(value) 334 | if expandtabs: 335 | text = text.expandtabs() 336 | results.append(text) 337 | return results 338 | 339 | 340 | def highlight_column_name(name): 341 | return ansi_wrap(name, bold=True, color=HIGHLIGHT_COLOR) 342 | -------------------------------------------------------------------------------- /humanfriendly/terminal/html.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: February 29, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """Convert HTML with simple text formatting to text with ANSI escape sequences.""" 8 | 9 | # Standard library modules. 10 | import re 11 | 12 | # Modules included in our package. 13 | from humanfriendly.compat import HTMLParser, StringIO, name2codepoint, unichr 14 | from humanfriendly.text import compact_empty_lines 15 | from humanfriendly.terminal import ANSI_COLOR_CODES, ANSI_RESET, ansi_style 16 | 17 | # Public identifiers that require documentation. 18 | __all__ = ('HTMLConverter', 'html_to_ansi') 19 | 20 | 21 | def html_to_ansi(data, callback=None): 22 | """ 23 | Convert HTML with simple text formatting to text with ANSI escape sequences. 24 | 25 | :param data: The HTML to convert (a string). 26 | :param callback: Optional callback to pass to :class:`HTMLConverter`. 27 | :returns: Text with ANSI escape sequences (a string). 28 | 29 | Please refer to the documentation of the :class:`HTMLConverter` class for 30 | details about the conversion process (like which tags are supported) and an 31 | example with a screenshot. 32 | """ 33 | converter = HTMLConverter(callback=callback) 34 | return converter(data) 35 | 36 | 37 | class HTMLConverter(HTMLParser): 38 | 39 | """ 40 | Convert HTML with simple text formatting to text with ANSI escape sequences. 41 | 42 | The following text styles are supported: 43 | 44 | - Bold: ````, ```` and ```` 45 | - Italic: ````, ```` and ```` 46 | - Strike-through: ````, ```` and ```` 47 | - Underline: ````, ```` and ```` 48 | 49 | Colors can be specified as follows: 50 | 51 | - Foreground color: ```` 52 | - Background color: ```` 53 | 54 | Here's a small demonstration: 55 | 56 | .. code-block:: python 57 | 58 | from humanfriendly.text import dedent 59 | from humanfriendly.terminal import html_to_ansi 60 | 61 | print(html_to_ansi(dedent(''' 62 | Hello world! 63 | Is this thing on? 64 | I guess I can underline or strike-through text? 65 | And what about color? 66 | '''))) 67 | 68 | rainbow_colors = [ 69 | '#FF0000', '#E2571E', '#FF7F00', '#FFFF00', '#00FF00', 70 | '#96BF33', '#0000FF', '#4B0082', '#8B00FF', '#FFFFFF', 71 | ] 72 | html_rainbow = "".join('o' % c for c in rainbow_colors) 73 | print(html_to_ansi("Let's try a rainbow: %s" % html_rainbow)) 74 | 75 | Here's what the results look like: 76 | 77 | .. image:: images/html-to-ansi.png 78 | 79 | Some more details: 80 | 81 | - Nested tags are supported, within reasonable limits. 82 | 83 | - Text in ```` and ``
`` tags will be highlighted in a
 84 |       different color from the main text (currently this is yellow).
 85 | 
 86 |     - ``TEXT`` is converted to the format "TEXT (URL)" where
 87 |       the uppercase symbols are highlighted in light blue with an underline.
 88 | 
 89 |     - ``
``, ``

`` and ``

`` tags are considered block level tags
 90 |       and are wrapped in vertical whitespace to prevent their content from
 91 |       "running into" surrounding text. This may cause runs of multiple empty
 92 |       lines to be emitted. As a *workaround* the :func:`__call__()` method
 93 |       will automatically call :func:`.compact_empty_lines()` on the generated
 94 |       output before returning it to the caller. Of course this won't work
 95 |       when `output` is set to something like :data:`sys.stdout`.
 96 | 
 97 |     - ``
`` is converted to a single plain text line break. 98 | 99 | Implementation notes: 100 | 101 | - A list of dictionaries with style information is used as a stack where 102 | new styling can be pushed and a pop will restore the previous styling. 103 | When new styling is pushed, it is merged with (but overrides) the current 104 | styling. 105 | 106 | - If you're going to be converting a lot of HTML it might be useful from 107 | a performance standpoint to re-use an existing :class:`HTMLConverter` 108 | object for unrelated HTML fragments, in this case take a look at the 109 | :func:`__call__()` method (it makes this use case very easy). 110 | 111 | .. versionadded:: 4.15 112 | :class:`humanfriendly.terminal.HTMLConverter` was added to the 113 | `humanfriendly` package during the initial development of my new 114 | `chat-archive `_ project, whose 115 | command line interface makes for a great demonstration of the 116 | flexibility that this feature provides (hint: check out how the search 117 | keyword highlighting combines with the regular highlighting). 118 | """ 119 | 120 | BLOCK_TAGS = ('div', 'p', 'pre') 121 | """The names of tags that are padded with vertical whitespace.""" 122 | 123 | def __init__(self, *args, **kw): 124 | """ 125 | Initialize an :class:`HTMLConverter` object. 126 | 127 | :param callback: Optional keyword argument to specify a function that 128 | will be called to process text fragments before they 129 | are emitted on the output stream. Note that link text 130 | and preformatted text fragments are not processed by 131 | this callback. 132 | :param output: Optional keyword argument to redirect the output to the 133 | given file-like object. If this is not given a new 134 | :class:`~python3:io.StringIO` object is created. 135 | """ 136 | # Hide our optional keyword arguments from the superclass. 137 | self.callback = kw.pop("callback", None) 138 | self.output = kw.pop("output", None) 139 | # Initialize the superclass. 140 | HTMLParser.__init__(self, *args, **kw) 141 | 142 | def __call__(self, data): 143 | """ 144 | Reset the parser, convert some HTML and get the text with ANSI escape sequences. 145 | 146 | :param data: The HTML to convert to text (a string). 147 | :returns: The converted text (only in case `output` is 148 | a :class:`~python3:io.StringIO` object). 149 | """ 150 | self.reset() 151 | self.feed(data) 152 | self.close() 153 | if isinstance(self.output, StringIO): 154 | return compact_empty_lines(self.output.getvalue()) 155 | 156 | @property 157 | def current_style(self): 158 | """Get the current style from the top of the stack (a dictionary).""" 159 | return self.stack[-1] if self.stack else {} 160 | 161 | def close(self): 162 | """ 163 | Close previously opened ANSI escape sequences. 164 | 165 | This method overrides the same method in the superclass to ensure that 166 | an :data:`.ANSI_RESET` code is emitted when parsing reaches the end of 167 | the input but a style is still active. This is intended to prevent 168 | malformed HTML from messing up terminal output. 169 | """ 170 | if any(self.stack): 171 | self.output.write(ANSI_RESET) 172 | self.stack = [] 173 | HTMLParser.close(self) 174 | 175 | def emit_style(self, style=None): 176 | """ 177 | Emit an ANSI escape sequence for the given or current style to the output stream. 178 | 179 | :param style: A dictionary with arguments for :func:`.ansi_style()` or 180 | :data:`None`, in which case the style at the top of the 181 | stack is emitted. 182 | """ 183 | # Clear the current text styles. 184 | self.output.write(ANSI_RESET) 185 | # Apply a new text style? 186 | style = self.current_style if style is None else style 187 | if style: 188 | self.output.write(ansi_style(**style)) 189 | 190 | def handle_charref(self, value): 191 | """ 192 | Process a decimal or hexadecimal numeric character reference. 193 | 194 | :param value: The decimal or hexadecimal value (a string). 195 | """ 196 | self.output.write(unichr(int(value[1:], 16) if value.startswith('x') else int(value))) 197 | 198 | def handle_data(self, data): 199 | """ 200 | Process textual data. 201 | 202 | :param data: The decoded text (a string). 203 | """ 204 | if self.link_url: 205 | # Link text is captured literally so that we can reliably check 206 | # whether the text and the URL of the link are the same string. 207 | self.link_text = data 208 | elif self.callback and self.preformatted_text_level == 0: 209 | # Text that is not part of a link and not preformatted text is 210 | # passed to the user defined callback to allow for arbitrary 211 | # pre-processing. 212 | data = self.callback(data) 213 | # All text is emitted unmodified on the output stream. 214 | self.output.write(data) 215 | 216 | def handle_endtag(self, tag): 217 | """ 218 | Process the end of an HTML tag. 219 | 220 | :param tag: The name of the tag (a string). 221 | """ 222 | if tag in ('a', 'b', 'code', 'del', 'em', 'i', 'ins', 'pre', 's', 'strong', 'span', 'u'): 223 | old_style = self.current_style 224 | # The following conditional isn't necessary for well formed 225 | # HTML but prevents raising exceptions on malformed HTML. 226 | if self.stack: 227 | self.stack.pop(-1) 228 | new_style = self.current_style 229 | if tag == 'a': 230 | if self.urls_match(self.link_text, self.link_url): 231 | # Don't render the URL when it's part of the link text. 232 | self.emit_style(new_style) 233 | else: 234 | self.emit_style(new_style) 235 | self.output.write(' (') 236 | self.emit_style(old_style) 237 | self.output.write(self.render_url(self.link_url)) 238 | self.emit_style(new_style) 239 | self.output.write(')') 240 | else: 241 | self.emit_style(new_style) 242 | if tag in ('code', 'pre'): 243 | self.preformatted_text_level -= 1 244 | if tag in self.BLOCK_TAGS: 245 | # Emit an empty line after block level tags. 246 | self.output.write('\n\n') 247 | 248 | def handle_entityref(self, name): 249 | """ 250 | Process a named character reference. 251 | 252 | :param name: The name of the character reference (a string). 253 | """ 254 | self.output.write(unichr(name2codepoint[name])) 255 | 256 | def handle_starttag(self, tag, attrs): 257 | """ 258 | Process the start of an HTML tag. 259 | 260 | :param tag: The name of the tag (a string). 261 | :param attrs: A list of tuples with two strings each. 262 | """ 263 | if tag in self.BLOCK_TAGS: 264 | # Emit an empty line before block level tags. 265 | self.output.write('\n\n') 266 | if tag == 'a': 267 | self.push_styles(color='blue', bright=True, underline=True) 268 | # Store the URL that the link points to for later use, so that we 269 | # can render the link text before the URL (with the reasoning that 270 | # this is the most intuitive way to present a link in a plain text 271 | # interface). 272 | self.link_url = next((v for n, v in attrs if n == 'href'), '') 273 | elif tag == 'b' or tag == 'strong': 274 | self.push_styles(bold=True) 275 | elif tag == 'br': 276 | self.output.write('\n') 277 | elif tag == 'code' or tag == 'pre': 278 | self.push_styles(color='yellow') 279 | self.preformatted_text_level += 1 280 | elif tag == 'del' or tag == 's': 281 | self.push_styles(strike_through=True) 282 | elif tag == 'em' or tag == 'i': 283 | self.push_styles(italic=True) 284 | elif tag == 'ins' or tag == 'u': 285 | self.push_styles(underline=True) 286 | elif tag == 'span': 287 | styles = {} 288 | css = next((v for n, v in attrs if n == 'style'), "") 289 | for rule in css.split(';'): 290 | name, _, value = rule.partition(':') 291 | name = name.strip() 292 | value = value.strip() 293 | if name == 'background-color': 294 | styles['background'] = self.parse_color(value) 295 | elif name == 'color': 296 | styles['color'] = self.parse_color(value) 297 | elif name == 'font-style' and value == 'italic': 298 | styles['italic'] = True 299 | elif name == 'font-weight' and value == 'bold': 300 | styles['bold'] = True 301 | elif name == 'text-decoration' and value == 'line-through': 302 | styles['strike_through'] = True 303 | elif name == 'text-decoration' and value == 'underline': 304 | styles['underline'] = True 305 | self.push_styles(**styles) 306 | 307 | def normalize_url(self, url): 308 | """ 309 | Normalize a URL to enable string equality comparison. 310 | 311 | :param url: The URL to normalize (a string). 312 | :returns: The normalized URL (a string). 313 | """ 314 | return re.sub('^mailto:', '', url) 315 | 316 | def parse_color(self, value): 317 | """ 318 | Convert a CSS color to something that :func:`.ansi_style()` understands. 319 | 320 | :param value: A string like ``rgb(1,2,3)``, ``#AABBCC`` or ``yellow``. 321 | :returns: A color value supported by :func:`.ansi_style()` or :data:`None`. 322 | """ 323 | # Parse an 'rgb(N,N,N)' expression. 324 | if value.startswith('rgb'): 325 | tokens = re.findall(r'\d+', value) 326 | if len(tokens) == 3: 327 | return tuple(map(int, tokens)) 328 | # Parse an '#XXXXXX' expression. 329 | elif value.startswith('#'): 330 | value = value[1:] 331 | length = len(value) 332 | if length == 6: 333 | # Six hex digits (proper notation). 334 | return ( 335 | int(value[:2], 16), 336 | int(value[2:4], 16), 337 | int(value[4:6], 16), 338 | ) 339 | elif length == 3: 340 | # Three hex digits (shorthand). 341 | return ( 342 | int(value[0], 16), 343 | int(value[1], 16), 344 | int(value[2], 16), 345 | ) 346 | # Try to recognize a named color. 347 | value = value.lower() 348 | if value in ANSI_COLOR_CODES: 349 | return value 350 | 351 | def push_styles(self, **changes): 352 | """ 353 | Push new style information onto the stack. 354 | 355 | :param changes: Any keyword arguments are passed on to :func:`.ansi_style()`. 356 | 357 | This method is a helper for :func:`handle_starttag()` 358 | that does the following: 359 | 360 | 1. Make a copy of the current styles (from the top of the stack), 361 | 2. Apply the given `changes` to the copy of the current styles, 362 | 3. Add the new styles to the stack, 363 | 4. Emit the appropriate ANSI escape sequence to the output stream. 364 | """ 365 | prototype = self.current_style 366 | if prototype: 367 | new_style = dict(prototype) 368 | new_style.update(changes) 369 | else: 370 | new_style = changes 371 | self.stack.append(new_style) 372 | self.emit_style(new_style) 373 | 374 | def render_url(self, url): 375 | """ 376 | Prepare a URL for rendering on the terminal. 377 | 378 | :param url: The URL to simplify (a string). 379 | :returns: The simplified URL (a string). 380 | 381 | This method pre-processes a URL before rendering on the terminal. The 382 | following modifications are made: 383 | 384 | - The ``mailto:`` prefix is stripped. 385 | - Spaces are converted to ``%20``. 386 | - A trailing parenthesis is converted to ``%29``. 387 | """ 388 | url = re.sub('^mailto:', '', url) 389 | url = re.sub(' ', '%20', url) 390 | url = re.sub(r'\)$', '%29', url) 391 | return url 392 | 393 | def reset(self): 394 | """ 395 | Reset the state of the HTML parser and ANSI converter. 396 | 397 | When `output` is a :class:`~python3:io.StringIO` object a new 398 | instance will be created (and the old one garbage collected). 399 | """ 400 | # Reset the state of the superclass. 401 | HTMLParser.reset(self) 402 | # Reset our instance variables. 403 | self.link_text = None 404 | self.link_url = None 405 | self.preformatted_text_level = 0 406 | if self.output is None or isinstance(self.output, StringIO): 407 | # If the caller specified something like output=sys.stdout then it 408 | # doesn't make much sense to negate that choice here in reset(). 409 | self.output = StringIO() 410 | self.stack = [] 411 | 412 | def urls_match(self, a, b): 413 | """ 414 | Compare two URLs for equality using :func:`normalize_url()`. 415 | 416 | :param a: A string containing a URL. 417 | :param b: A string containing a URL. 418 | :returns: :data:`True` if the URLs are the same, :data:`False` otherwise. 419 | 420 | This method is used by :func:`handle_endtag()` to omit the URL of a 421 | hyperlink (````) when the link text is that same URL. 422 | """ 423 | return self.normalize_url(a) == self.normalize_url(b) 424 | -------------------------------------------------------------------------------- /humanfriendly/terminal/spinners.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 1, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Support for spinners that represent progress on interactive terminals. 9 | 10 | The :class:`Spinner` class shows a "spinner" on the terminal to let the user 11 | know that something is happening during long running operations that would 12 | otherwise be silent (leaving the user to wonder what they're waiting for). 13 | Below are some visual examples that should illustrate the point. 14 | 15 | **Simple spinners:** 16 | 17 | Here's a screen capture that shows the simplest form of spinner: 18 | 19 | .. image:: images/spinner-basic.gif 20 | :alt: Animated screen capture of a simple spinner. 21 | 22 | The following code was used to create the spinner above: 23 | 24 | .. code-block:: python 25 | 26 | import itertools 27 | import time 28 | from humanfriendly import Spinner 29 | 30 | with Spinner(label="Downloading") as spinner: 31 | for i in itertools.count(): 32 | # Do something useful here. 33 | time.sleep(0.1) 34 | # Advance the spinner. 35 | spinner.step() 36 | 37 | **Spinners that show elapsed time:** 38 | 39 | Here's a spinner that shows the elapsed time since it started: 40 | 41 | .. image:: images/spinner-with-timer.gif 42 | :alt: Animated screen capture of a spinner showing elapsed time. 43 | 44 | The following code was used to create the spinner above: 45 | 46 | .. code-block:: python 47 | 48 | import itertools 49 | import time 50 | from humanfriendly import Spinner, Timer 51 | 52 | with Spinner(label="Downloading", timer=Timer()) as spinner: 53 | for i in itertools.count(): 54 | # Do something useful here. 55 | time.sleep(0.1) 56 | # Advance the spinner. 57 | spinner.step() 58 | 59 | **Spinners that show progress:** 60 | 61 | Here's a spinner that shows a progress percentage: 62 | 63 | .. image:: images/spinner-with-progress.gif 64 | :alt: Animated screen capture of spinner showing progress. 65 | 66 | The following code was used to create the spinner above: 67 | 68 | .. code-block:: python 69 | 70 | import itertools 71 | import random 72 | import time 73 | from humanfriendly import Spinner, Timer 74 | 75 | with Spinner(label="Downloading", total=100) as spinner: 76 | progress = 0 77 | while progress < 100: 78 | # Do something useful here. 79 | time.sleep(0.1) 80 | # Advance the spinner. 81 | spinner.step(progress) 82 | # Determine the new progress value. 83 | progress += random.random() * 5 84 | 85 | If you want to provide user feedback during a long running operation but it's 86 | not practical to periodically call the :func:`~Spinner.step()` method consider 87 | using :class:`AutomaticSpinner` instead. 88 | 89 | As you may already have noticed in the examples above, :class:`Spinner` objects 90 | can be used as context managers to automatically call :func:`Spinner.clear()` 91 | when the spinner ends. 92 | """ 93 | 94 | # Standard library modules. 95 | import multiprocessing 96 | import sys 97 | import time 98 | 99 | # Modules included in our package. 100 | from humanfriendly import Timer 101 | from humanfriendly.deprecation import deprecated_args 102 | from humanfriendly.terminal import ANSI_ERASE_LINE 103 | 104 | # Public identifiers that require documentation. 105 | __all__ = ("AutomaticSpinner", "GLYPHS", "MINIMUM_INTERVAL", "Spinner") 106 | 107 | GLYPHS = ["-", "\\", "|", "/"] 108 | """A list of strings with characters that together form a crude animation :-).""" 109 | 110 | MINIMUM_INTERVAL = 0.2 111 | """Spinners are redrawn with a frequency no higher than this number (a floating point number of seconds).""" 112 | 113 | 114 | class Spinner(object): 115 | 116 | """Show a spinner on the terminal as a simple means of feedback to the user.""" 117 | 118 | @deprecated_args('label', 'total', 'stream', 'interactive', 'timer') 119 | def __init__(self, **options): 120 | """ 121 | Initialize a :class:`Spinner` object. 122 | 123 | :param label: 124 | 125 | The label for the spinner (a string or :data:`None`, defaults to 126 | :data:`None`). 127 | 128 | :param total: 129 | 130 | The expected number of steps (an integer or :data:`None`). If this is 131 | provided the spinner will show a progress percentage. 132 | 133 | :param stream: 134 | 135 | The output stream to show the spinner on (a file-like object, 136 | defaults to :data:`sys.stderr`). 137 | 138 | :param interactive: 139 | 140 | :data:`True` to enable rendering of the spinner, :data:`False` to 141 | disable (defaults to the result of ``stream.isatty()``). 142 | 143 | :param timer: 144 | 145 | A :class:`.Timer` object (optional). If this is given the spinner 146 | will show the elapsed time according to the timer. 147 | 148 | :param interval: 149 | 150 | The spinner will be updated at most once every this many seconds 151 | (a floating point number, defaults to :data:`MINIMUM_INTERVAL`). 152 | 153 | :param glyphs: 154 | 155 | A list of strings with single characters that are drawn in the same 156 | place in succession to implement a simple animated effect (defaults 157 | to :data:`GLYPHS`). 158 | """ 159 | # Store initializer arguments. 160 | self.interactive = options.get('interactive') 161 | self.interval = options.get('interval', MINIMUM_INTERVAL) 162 | self.label = options.get('label') 163 | self.states = options.get('glyphs', GLYPHS) 164 | self.stream = options.get('stream', sys.stderr) 165 | self.timer = options.get('timer') 166 | self.total = options.get('total') 167 | # Define instance variables. 168 | self.counter = 0 169 | self.last_update = 0 170 | # Try to automatically discover whether the stream is connected to 171 | # a terminal, but don't fail if no isatty() method is available. 172 | if self.interactive is None: 173 | try: 174 | self.interactive = self.stream.isatty() 175 | except Exception: 176 | self.interactive = False 177 | 178 | def step(self, progress=0, label=None): 179 | """ 180 | Advance the spinner by one step and redraw it. 181 | 182 | :param progress: The number of the current step, relative to the total 183 | given to the :class:`Spinner` constructor (an integer, 184 | optional). If not provided the spinner will not show 185 | progress. 186 | :param label: The label to use while redrawing (a string, optional). If 187 | not provided the label given to the :class:`Spinner` 188 | constructor is used instead. 189 | 190 | This method advances the spinner by one step without starting a new 191 | line, causing an animated effect which is very simple but much nicer 192 | than waiting for a prompt which is completely silent for a long time. 193 | 194 | .. note:: This method uses time based rate limiting to avoid redrawing 195 | the spinner too frequently. If you know you're dealing with 196 | code that will call :func:`step()` at a high frequency, 197 | consider using :func:`sleep()` to avoid creating the 198 | equivalent of a busy loop that's rate limiting the spinner 199 | 99% of the time. 200 | """ 201 | if self.interactive: 202 | time_now = time.time() 203 | if time_now - self.last_update >= self.interval: 204 | self.last_update = time_now 205 | state = self.states[self.counter % len(self.states)] 206 | label = label or self.label 207 | if not label: 208 | raise Exception("No label set for spinner!") 209 | elif self.total and progress: 210 | label = "%s: %.2f%%" % (label, progress / (self.total / 100.0)) 211 | elif self.timer and self.timer.elapsed_time > 2: 212 | label = "%s (%s)" % (label, self.timer.rounded) 213 | self.stream.write("%s %s %s ..\r" % (ANSI_ERASE_LINE, state, label)) 214 | self.counter += 1 215 | 216 | def sleep(self): 217 | """ 218 | Sleep for a short period before redrawing the spinner. 219 | 220 | This method is useful when you know you're dealing with code that will 221 | call :func:`step()` at a high frequency. It will sleep for the interval 222 | with which the spinner is redrawn (less than a second). This avoids 223 | creating the equivalent of a busy loop that's rate limiting the 224 | spinner 99% of the time. 225 | 226 | This method doesn't redraw the spinner, you still have to call 227 | :func:`step()` in order to do that. 228 | """ 229 | time.sleep(MINIMUM_INTERVAL) 230 | 231 | def clear(self): 232 | """ 233 | Clear the spinner. 234 | 235 | The next line which is shown on the standard output or error stream 236 | after calling this method will overwrite the line that used to show the 237 | spinner. 238 | """ 239 | if self.interactive: 240 | self.stream.write(ANSI_ERASE_LINE) 241 | 242 | def __enter__(self): 243 | """ 244 | Enable the use of spinners as context managers. 245 | 246 | :returns: The :class:`Spinner` object. 247 | """ 248 | return self 249 | 250 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 251 | """Clear the spinner when leaving the context.""" 252 | self.clear() 253 | 254 | 255 | class AutomaticSpinner(object): 256 | 257 | """ 258 | Show a spinner on the terminal that automatically starts animating. 259 | 260 | This class shows a spinner on the terminal (just like :class:`Spinner` 261 | does) that automatically starts animating. This class should be used as a 262 | context manager using the :keyword:`with` statement. The animation 263 | continues for as long as the context is active. 264 | 265 | :class:`AutomaticSpinner` provides an alternative to :class:`Spinner` 266 | for situations where it is not practical for the caller to periodically 267 | call :func:`~Spinner.step()` to advance the animation, e.g. because 268 | you're performing a blocking call and don't fancy implementing threading or 269 | subprocess handling just to provide some user feedback. 270 | 271 | This works using the :mod:`multiprocessing` module by spawning a 272 | subprocess to render the spinner while the main process is busy doing 273 | something more useful. By using the :keyword:`with` statement you're 274 | guaranteed that the subprocess is properly terminated at the appropriate 275 | time. 276 | """ 277 | 278 | def __init__(self, label, show_time=True): 279 | """ 280 | Initialize an automatic spinner. 281 | 282 | :param label: The label for the spinner (a string). 283 | :param show_time: If this is :data:`True` (the default) then the spinner 284 | shows elapsed time. 285 | """ 286 | self.label = label 287 | self.show_time = show_time 288 | self.shutdown_event = multiprocessing.Event() 289 | self.subprocess = multiprocessing.Process(target=self._target) 290 | 291 | def __enter__(self): 292 | """Enable the use of automatic spinners as context managers.""" 293 | self.subprocess.start() 294 | 295 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 296 | """Enable the use of automatic spinners as context managers.""" 297 | self.shutdown_event.set() 298 | self.subprocess.join() 299 | 300 | def _target(self): 301 | try: 302 | timer = Timer() if self.show_time else None 303 | with Spinner(label=self.label, timer=timer) as spinner: 304 | while not self.shutdown_event.is_set(): 305 | spinner.step() 306 | spinner.sleep() 307 | except KeyboardInterrupt: 308 | # Swallow Control-C signals without producing a nasty traceback that 309 | # won't make any sense to the average user. 310 | pass 311 | -------------------------------------------------------------------------------- /humanfriendly/testing.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 6, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Utility classes and functions that make it easy to write :mod:`unittest` compatible test suites. 9 | 10 | Over the years I've developed the habit of writing test suites for Python 11 | projects using the :mod:`unittest` module. During those years I've come to know 12 | :pypi:`pytest` and in fact I use :pypi:`pytest` to run my test suites (due to 13 | its much better error reporting) but I've yet to publish a test suite that 14 | *requires* :pypi:`pytest`. I have several reasons for doing so: 15 | 16 | - It's nice to keep my test suites as simple and accessible as possible and 17 | not requiring a specific test runner is part of that attitude. 18 | 19 | - Whereas :mod:`unittest` is quite explicit, :pypi:`pytest` contains a lot of 20 | magic, which kind of contradicts the Python mantra "explicit is better than 21 | implicit" (IMHO). 22 | """ 23 | 24 | # Standard library module 25 | import functools 26 | import logging 27 | import os 28 | import pipes 29 | import shutil 30 | import sys 31 | import tempfile 32 | import time 33 | import unittest 34 | 35 | # Modules included in our package. 36 | from humanfriendly.compat import StringIO 37 | from humanfriendly.text import random_string 38 | 39 | # Initialize a logger for this module. 40 | logger = logging.getLogger(__name__) 41 | 42 | # A unique object reference used to detect missing attributes. 43 | NOTHING = object() 44 | 45 | # Public identifiers that require documentation. 46 | __all__ = ( 47 | 'CallableTimedOut', 48 | 'CaptureBuffer', 49 | 'CaptureOutput', 50 | 'ContextManager', 51 | 'CustomSearchPath', 52 | 'MockedProgram', 53 | 'PatchedAttribute', 54 | 'PatchedItem', 55 | 'TemporaryDirectory', 56 | 'TestCase', 57 | 'configure_logging', 58 | 'make_dirs', 59 | 'retry', 60 | 'run_cli', 61 | 'skip_on_raise', 62 | 'touch', 63 | ) 64 | 65 | 66 | def configure_logging(log_level=logging.DEBUG): 67 | """configure_logging(log_level=logging.DEBUG) 68 | Automatically configure logging to the terminal. 69 | 70 | :param log_level: The log verbosity (a number, defaults 71 | to :mod:`logging.DEBUG `). 72 | 73 | When :mod:`coloredlogs` is installed :func:`coloredlogs.install()` will be 74 | used to configure logging to the terminal. When this fails with an 75 | :exc:`~exceptions.ImportError` then :func:`logging.basicConfig()` is used 76 | as a fall back. 77 | """ 78 | try: 79 | import coloredlogs 80 | coloredlogs.install(level=log_level) 81 | except ImportError: 82 | logging.basicConfig( 83 | level=log_level, 84 | format='%(asctime)s %(name)s[%(process)d] %(levelname)s %(message)s', 85 | datefmt='%Y-%m-%d %H:%M:%S') 86 | 87 | 88 | def make_dirs(pathname): 89 | """ 90 | Create missing directories. 91 | 92 | :param pathname: The pathname of a directory (a string). 93 | """ 94 | if not os.path.isdir(pathname): 95 | os.makedirs(pathname) 96 | 97 | 98 | def retry(func, timeout=60, exc_type=AssertionError): 99 | """retry(func, timeout=60, exc_type=AssertionError) 100 | Retry a function until assertions no longer fail. 101 | 102 | :param func: A callable. When the callable returns 103 | :data:`False` it will also be retried. 104 | :param timeout: The number of seconds after which to abort (a number, 105 | defaults to 60). 106 | :param exc_type: The type of exceptions to retry (defaults 107 | to :exc:`~exceptions.AssertionError`). 108 | :returns: The value returned by `func`. 109 | :raises: Once the timeout has expired :func:`retry()` will raise the 110 | previously retried assertion error. When `func` keeps returning 111 | :data:`False` until `timeout` expires :exc:`CallableTimedOut` 112 | will be raised. 113 | 114 | This function sleeps between retries to avoid claiming CPU cycles we don't 115 | need. It starts by sleeping for 0.1 second but adjusts this to one second 116 | as the number of retries grows. 117 | """ 118 | pause = 0.1 119 | timeout += time.time() 120 | while True: 121 | try: 122 | result = func() 123 | if result is not False: 124 | return result 125 | except exc_type: 126 | if time.time() > timeout: 127 | raise 128 | else: 129 | if time.time() > timeout: 130 | raise CallableTimedOut() 131 | time.sleep(pause) 132 | if pause < 1: 133 | pause *= 2 134 | 135 | 136 | def run_cli(entry_point, *arguments, **options): 137 | """ 138 | Test a command line entry point. 139 | 140 | :param entry_point: The function that implements the command line interface 141 | (a callable). 142 | :param arguments: Any positional arguments (strings) become the command 143 | line arguments (:data:`sys.argv` items 1-N). 144 | :param options: The following keyword arguments are supported: 145 | 146 | **capture** 147 | Whether to use :class:`CaptureOutput`. Defaults 148 | to :data:`True` but can be disabled by passing 149 | :data:`False` instead. 150 | **input** 151 | Refer to :class:`CaptureOutput`. 152 | **merged** 153 | Refer to :class:`CaptureOutput`. 154 | **program_name** 155 | Used to set :data:`sys.argv` item 0. 156 | :returns: A tuple with two values: 157 | 158 | 1. The return code (an integer). 159 | 2. The captured output (a string). 160 | """ 161 | # Add the `program_name' option to the arguments. 162 | arguments = list(arguments) 163 | arguments.insert(0, options.pop('program_name', sys.executable)) 164 | # Log the command line arguments (and the fact that we're about to call the 165 | # command line entry point function). 166 | logger.debug("Calling command line entry point with arguments: %s", arguments) 167 | # Prepare to capture the return code and output even if the command line 168 | # interface raises an exception (whether the exception type is SystemExit 169 | # or something else). 170 | returncode = 0 171 | stdout = None 172 | stderr = None 173 | try: 174 | # Temporarily override sys.argv. 175 | with PatchedAttribute(sys, 'argv', arguments): 176 | # Manipulate the standard input/output/error streams? 177 | options['enabled'] = options.pop('capture', True) 178 | with CaptureOutput(**options) as capturer: 179 | try: 180 | # Call the command line interface. 181 | entry_point() 182 | finally: 183 | # Get the output even if an exception is raised. 184 | stdout = capturer.stdout.getvalue() 185 | stderr = capturer.stderr.getvalue() 186 | # Reconfigure logging to the terminal because it is very 187 | # likely that the entry point function has changed the 188 | # configured log level. 189 | configure_logging() 190 | except BaseException as e: 191 | if isinstance(e, SystemExit): 192 | logger.debug("Intercepting return code %s from SystemExit exception.", e.code) 193 | returncode = e.code 194 | else: 195 | logger.warning("Defaulting return code to 1 due to raised exception.", exc_info=True) 196 | returncode = 1 197 | else: 198 | logger.debug("Command line entry point returned successfully!") 199 | # Always log the output captured on stdout/stderr, to make it easier to 200 | # diagnose test failures (but avoid duplicate logging when merged=True). 201 | is_merged = options.get('merged', False) 202 | merged_streams = [('merged streams', stdout)] 203 | separate_streams = [('stdout', stdout), ('stderr', stderr)] 204 | streams = merged_streams if is_merged else separate_streams 205 | for name, value in streams: 206 | if value: 207 | logger.debug("Output on %s:\n%s", name, value) 208 | else: 209 | logger.debug("No output on %s.", name) 210 | return returncode, stdout 211 | 212 | 213 | def skip_on_raise(*exc_types): 214 | """ 215 | Decorate a test function to translation specific exception types to :exc:`unittest.SkipTest`. 216 | 217 | :param exc_types: One or more positional arguments give the exception 218 | types to be translated to :exc:`unittest.SkipTest`. 219 | :returns: A decorator function specialized to `exc_types`. 220 | """ 221 | def decorator(function): 222 | @functools.wraps(function) 223 | def wrapper(*args, **kw): 224 | try: 225 | return function(*args, **kw) 226 | except exc_types as e: 227 | logger.debug("Translating exception to unittest.SkipTest ..", exc_info=True) 228 | raise unittest.SkipTest("skipping test because %s was raised" % type(e)) 229 | return wrapper 230 | return decorator 231 | 232 | 233 | def touch(filename): 234 | """ 235 | The equivalent of the UNIX :man:`touch` program in Python. 236 | 237 | :param filename: The pathname of the file to touch (a string). 238 | 239 | Note that missing directories are automatically created using 240 | :func:`make_dirs()`. 241 | """ 242 | make_dirs(os.path.dirname(filename)) 243 | with open(filename, 'a'): 244 | os.utime(filename, None) 245 | 246 | 247 | class CallableTimedOut(Exception): 248 | 249 | """Raised by :func:`retry()` when the timeout expires.""" 250 | 251 | 252 | class ContextManager(object): 253 | 254 | """Base class to enable composition of context managers.""" 255 | 256 | def __enter__(self): 257 | """Enable use as context managers.""" 258 | return self 259 | 260 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 261 | """Enable use as context managers.""" 262 | 263 | 264 | class PatchedAttribute(ContextManager): 265 | 266 | """Context manager that temporary replaces an object attribute using :func:`setattr()`.""" 267 | 268 | def __init__(self, obj, name, value): 269 | """ 270 | Initialize a :class:`PatchedAttribute` object. 271 | 272 | :param obj: The object to patch. 273 | :param name: An attribute name. 274 | :param value: The value to set. 275 | """ 276 | self.object_to_patch = obj 277 | self.attribute_to_patch = name 278 | self.patched_value = value 279 | self.original_value = NOTHING 280 | 281 | def __enter__(self): 282 | """ 283 | Replace (patch) the attribute. 284 | 285 | :returns: The object whose attribute was patched. 286 | """ 287 | # Enable composition of context managers. 288 | super(PatchedAttribute, self).__enter__() 289 | # Patch the object's attribute. 290 | self.original_value = getattr(self.object_to_patch, self.attribute_to_patch, NOTHING) 291 | setattr(self.object_to_patch, self.attribute_to_patch, self.patched_value) 292 | return self.object_to_patch 293 | 294 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 295 | """Restore the attribute to its original value.""" 296 | # Enable composition of context managers. 297 | super(PatchedAttribute, self).__exit__(exc_type, exc_value, traceback) 298 | # Restore the object's attribute. 299 | if self.original_value is NOTHING: 300 | delattr(self.object_to_patch, self.attribute_to_patch) 301 | else: 302 | setattr(self.object_to_patch, self.attribute_to_patch, self.original_value) 303 | 304 | 305 | class PatchedItem(ContextManager): 306 | 307 | """Context manager that temporary replaces an object item using :meth:`~object.__setitem__()`.""" 308 | 309 | def __init__(self, obj, item, value): 310 | """ 311 | Initialize a :class:`PatchedItem` object. 312 | 313 | :param obj: The object to patch. 314 | :param item: The item to patch. 315 | :param value: The value to set. 316 | """ 317 | self.object_to_patch = obj 318 | self.item_to_patch = item 319 | self.patched_value = value 320 | self.original_value = NOTHING 321 | 322 | def __enter__(self): 323 | """ 324 | Replace (patch) the item. 325 | 326 | :returns: The object whose item was patched. 327 | """ 328 | # Enable composition of context managers. 329 | super(PatchedItem, self).__enter__() 330 | # Patch the object's item. 331 | try: 332 | self.original_value = self.object_to_patch[self.item_to_patch] 333 | except KeyError: 334 | self.original_value = NOTHING 335 | self.object_to_patch[self.item_to_patch] = self.patched_value 336 | return self.object_to_patch 337 | 338 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 339 | """Restore the item to its original value.""" 340 | # Enable composition of context managers. 341 | super(PatchedItem, self).__exit__(exc_type, exc_value, traceback) 342 | # Restore the object's item. 343 | if self.original_value is NOTHING: 344 | del self.object_to_patch[self.item_to_patch] 345 | else: 346 | self.object_to_patch[self.item_to_patch] = self.original_value 347 | 348 | 349 | class TemporaryDirectory(ContextManager): 350 | 351 | """ 352 | Easy temporary directory creation & cleanup using the :keyword:`with` statement. 353 | 354 | Here's an example of how to use this: 355 | 356 | .. code-block:: python 357 | 358 | with TemporaryDirectory() as directory: 359 | # Do something useful here. 360 | assert os.path.isdir(directory) 361 | """ 362 | 363 | def __init__(self, **options): 364 | """ 365 | Initialize a :class:`TemporaryDirectory` object. 366 | 367 | :param options: Any keyword arguments are passed on to 368 | :func:`tempfile.mkdtemp()`. 369 | """ 370 | self.mkdtemp_options = options 371 | self.temporary_directory = None 372 | 373 | def __enter__(self): 374 | """ 375 | Create the temporary directory using :func:`tempfile.mkdtemp()`. 376 | 377 | :returns: The pathname of the directory (a string). 378 | """ 379 | # Enable composition of context managers. 380 | super(TemporaryDirectory, self).__enter__() 381 | # Create the temporary directory. 382 | self.temporary_directory = tempfile.mkdtemp(**self.mkdtemp_options) 383 | return self.temporary_directory 384 | 385 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 386 | """Cleanup the temporary directory using :func:`shutil.rmtree()`.""" 387 | # Enable composition of context managers. 388 | super(TemporaryDirectory, self).__exit__(exc_type, exc_value, traceback) 389 | # Cleanup the temporary directory. 390 | if self.temporary_directory is not None: 391 | shutil.rmtree(self.temporary_directory) 392 | self.temporary_directory = None 393 | 394 | 395 | class MockedHomeDirectory(PatchedItem, TemporaryDirectory): 396 | 397 | """ 398 | Context manager to temporarily change ``$HOME`` (the current user's profile directory). 399 | 400 | This class is a composition of the :class:`PatchedItem` and 401 | :class:`TemporaryDirectory` context managers. 402 | """ 403 | 404 | def __init__(self): 405 | """Initialize a :class:`MockedHomeDirectory` object.""" 406 | PatchedItem.__init__(self, os.environ, 'HOME', os.environ.get('HOME')) 407 | TemporaryDirectory.__init__(self) 408 | 409 | def __enter__(self): 410 | """ 411 | Activate the custom ``$PATH``. 412 | 413 | :returns: The pathname of the directory that has 414 | been added to ``$PATH`` (a string). 415 | """ 416 | # Get the temporary directory. 417 | directory = TemporaryDirectory.__enter__(self) 418 | # Override the value to patch now that we have 419 | # the pathname of the temporary directory. 420 | self.patched_value = directory 421 | # Temporary patch $HOME. 422 | PatchedItem.__enter__(self) 423 | # Pass the pathname of the temporary directory to the caller. 424 | return directory 425 | 426 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 427 | """Deactivate the custom ``$HOME``.""" 428 | super(MockedHomeDirectory, self).__exit__(exc_type, exc_value, traceback) 429 | 430 | 431 | class CustomSearchPath(PatchedItem, TemporaryDirectory): 432 | 433 | """ 434 | Context manager to temporarily customize ``$PATH`` (the executable search path). 435 | 436 | This class is a composition of the :class:`PatchedItem` and 437 | :class:`TemporaryDirectory` context managers. 438 | """ 439 | 440 | def __init__(self, isolated=False): 441 | """ 442 | Initialize a :class:`CustomSearchPath` object. 443 | 444 | :param isolated: :data:`True` to clear the original search path, 445 | :data:`False` to add the temporary directory to the 446 | start of the search path. 447 | """ 448 | # Initialize our own instance variables. 449 | self.isolated_search_path = isolated 450 | # Selectively initialize our superclasses. 451 | PatchedItem.__init__(self, os.environ, 'PATH', self.current_search_path) 452 | TemporaryDirectory.__init__(self) 453 | 454 | def __enter__(self): 455 | """ 456 | Activate the custom ``$PATH``. 457 | 458 | :returns: The pathname of the directory that has 459 | been added to ``$PATH`` (a string). 460 | """ 461 | # Get the temporary directory. 462 | directory = TemporaryDirectory.__enter__(self) 463 | # Override the value to patch now that we have 464 | # the pathname of the temporary directory. 465 | self.patched_value = ( 466 | directory if self.isolated_search_path 467 | else os.pathsep.join([directory] + self.current_search_path.split(os.pathsep)) 468 | ) 469 | # Temporary patch the $PATH. 470 | PatchedItem.__enter__(self) 471 | # Pass the pathname of the temporary directory to the caller 472 | # because they may want to `install' custom executables. 473 | return directory 474 | 475 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 476 | """Deactivate the custom ``$PATH``.""" 477 | super(CustomSearchPath, self).__exit__(exc_type, exc_value, traceback) 478 | 479 | @property 480 | def current_search_path(self): 481 | """The value of ``$PATH`` or :data:`os.defpath` (a string).""" 482 | return os.environ.get('PATH', os.defpath) 483 | 484 | 485 | class MockedProgram(CustomSearchPath): 486 | 487 | """ 488 | Context manager to mock the existence of a program (executable). 489 | 490 | This class extends the functionality of :class:`CustomSearchPath`. 491 | """ 492 | 493 | def __init__(self, name, returncode=0, script=None): 494 | """ 495 | Initialize a :class:`MockedProgram` object. 496 | 497 | :param name: The name of the program (a string). 498 | :param returncode: The return code that the program should emit (a 499 | number, defaults to zero). 500 | :param script: Shell script code to include in the mocked program (a 501 | string or :data:`None`). This can be used to mock a 502 | program that is expected to generate specific output. 503 | """ 504 | # Initialize our own instance variables. 505 | self.program_name = name 506 | self.program_returncode = returncode 507 | self.program_script = script 508 | self.program_signal_file = None 509 | # Initialize our superclasses. 510 | super(MockedProgram, self).__init__() 511 | 512 | def __enter__(self): 513 | """ 514 | Create the mock program. 515 | 516 | :returns: The pathname of the directory that has 517 | been added to ``$PATH`` (a string). 518 | """ 519 | directory = super(MockedProgram, self).__enter__() 520 | self.program_signal_file = os.path.join(directory, 'program-was-run-%s' % random_string(10)) 521 | pathname = os.path.join(directory, self.program_name) 522 | with open(pathname, 'w') as handle: 523 | handle.write('#!/bin/sh\n') 524 | handle.write('echo > %s\n' % pipes.quote(self.program_signal_file)) 525 | if self.program_script: 526 | handle.write('%s\n' % self.program_script.strip()) 527 | handle.write('exit %i\n' % self.program_returncode) 528 | os.chmod(pathname, 0o755) 529 | return directory 530 | 531 | def __exit__(self, *args, **kw): 532 | """ 533 | Ensure that the mock program was run. 534 | 535 | :raises: :exc:`~exceptions.AssertionError` when 536 | the mock program hasn't been run. 537 | """ 538 | try: 539 | assert self.program_signal_file and os.path.isfile(self.program_signal_file), \ 540 | ("It looks like %r was never run!" % self.program_name) 541 | finally: 542 | return super(MockedProgram, self).__exit__(*args, **kw) 543 | 544 | 545 | class CaptureOutput(ContextManager): 546 | 547 | """ 548 | Context manager that captures what's written to :data:`sys.stdout` and :data:`sys.stderr`. 549 | 550 | .. attribute:: stdin 551 | 552 | The :class:`~humanfriendly.compat.StringIO` object used to feed the standard input stream. 553 | 554 | .. attribute:: stdout 555 | 556 | The :class:`CaptureBuffer` object used to capture the standard output stream. 557 | 558 | .. attribute:: stderr 559 | 560 | The :class:`CaptureBuffer` object used to capture the standard error stream. 561 | """ 562 | 563 | def __init__(self, merged=False, input='', enabled=True): 564 | """ 565 | Initialize a :class:`CaptureOutput` object. 566 | 567 | :param merged: :data:`True` to merge the streams, 568 | :data:`False` to capture them separately. 569 | :param input: The data that reads from :data:`sys.stdin` 570 | should return (a string). 571 | :param enabled: :data:`True` to enable capturing (the default), 572 | :data:`False` otherwise. This makes it easy to 573 | unconditionally use :class:`CaptureOutput` in 574 | a :keyword:`with` block while preserving the 575 | choice to opt out of capturing output. 576 | """ 577 | self.stdin = StringIO(input) 578 | self.stdout = CaptureBuffer() 579 | self.stderr = self.stdout if merged else CaptureBuffer() 580 | self.patched_attributes = [] 581 | if enabled: 582 | self.patched_attributes.extend( 583 | PatchedAttribute(sys, name, getattr(self, name)) 584 | for name in ('stdin', 'stdout', 'stderr') 585 | ) 586 | 587 | def __enter__(self): 588 | """Start capturing what's written to :data:`sys.stdout` and :data:`sys.stderr`.""" 589 | super(CaptureOutput, self).__enter__() 590 | for context in self.patched_attributes: 591 | context.__enter__() 592 | return self 593 | 594 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 595 | """Stop capturing what's written to :data:`sys.stdout` and :data:`sys.stderr`.""" 596 | super(CaptureOutput, self).__exit__(exc_type, exc_value, traceback) 597 | for context in self.patched_attributes: 598 | context.__exit__(exc_type, exc_value, traceback) 599 | 600 | def get_lines(self): 601 | """Get the contents of :attr:`stdout` split into separate lines.""" 602 | return self.get_text().splitlines() 603 | 604 | def get_text(self): 605 | """Get the contents of :attr:`stdout` as a Unicode string.""" 606 | return self.stdout.get_text() 607 | 608 | def getvalue(self): 609 | """Get the text written to :data:`sys.stdout`.""" 610 | return self.stdout.getvalue() 611 | 612 | 613 | class CaptureBuffer(StringIO): 614 | 615 | """ 616 | Helper for :class:`CaptureOutput` to provide an easy to use API. 617 | 618 | The two methods defined by this subclass were specifically chosen to match 619 | the names of the methods provided by my :pypi:`capturer` package which 620 | serves a similar role as :class:`CaptureOutput` but knows how to simulate 621 | an interactive terminal (tty). 622 | """ 623 | 624 | def get_lines(self): 625 | """Get the contents of the buffer split into separate lines.""" 626 | return self.get_text().splitlines() 627 | 628 | def get_text(self): 629 | """Get the contents of the buffer as a Unicode string.""" 630 | return self.getvalue() 631 | 632 | 633 | class TestCase(unittest.TestCase): 634 | 635 | """Subclass of :class:`unittest.TestCase` with automatic logging and other miscellaneous features.""" 636 | 637 | def __init__(self, *args, **kw): 638 | """ 639 | Initialize a :class:`TestCase` object. 640 | 641 | Any positional and/or keyword arguments are passed on to the 642 | initializer of the superclass. 643 | """ 644 | super(TestCase, self).__init__(*args, **kw) 645 | 646 | def setUp(self, log_level=logging.DEBUG): 647 | """setUp(log_level=logging.DEBUG) 648 | Automatically configure logging to the terminal. 649 | 650 | :param log_level: Refer to :func:`configure_logging()`. 651 | 652 | The :func:`setUp()` method is automatically called by 653 | :class:`unittest.TestCase` before each test method starts. 654 | It does two things: 655 | 656 | - Logging to the terminal is configured using 657 | :func:`configure_logging()`. 658 | 659 | - Before the test method starts a newline is emitted, to separate the 660 | name of the test method (which will be printed to the terminal by 661 | :mod:`unittest` or :pypi:`pytest`) from the first line of logging 662 | output that the test method is likely going to generate. 663 | """ 664 | # Configure logging to the terminal. 665 | configure_logging(log_level) 666 | # Separate the name of the test method (printed by the superclass 667 | # and/or py.test without a newline at the end) from the first line of 668 | # logging output that the test method is likely going to generate. 669 | sys.stderr.write("\n") 670 | -------------------------------------------------------------------------------- /humanfriendly/text.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 1, 2020 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Simple text manipulation functions. 9 | 10 | The :mod:`~humanfriendly.text` module contains simple functions to manipulate text: 11 | 12 | - The :func:`concatenate()` and :func:`pluralize()` functions make it easy to 13 | generate human friendly output. 14 | 15 | - The :func:`format()`, :func:`compact()` and :func:`dedent()` functions 16 | provide a clean and simple to use syntax for composing large text fragments 17 | with interpolated variables. 18 | 19 | - The :func:`tokenize()` function parses simple user input. 20 | """ 21 | 22 | # Standard library modules. 23 | import numbers 24 | import random 25 | import re 26 | import string 27 | import textwrap 28 | 29 | # Public identifiers that require documentation. 30 | __all__ = ( 31 | 'compact', 32 | 'compact_empty_lines', 33 | 'concatenate', 34 | 'dedent', 35 | 'format', 36 | 'generate_slug', 37 | 'is_empty_line', 38 | 'join_lines', 39 | 'pluralize', 40 | 'pluralize_raw', 41 | 'random_string', 42 | 'split', 43 | 'split_paragraphs', 44 | 'tokenize', 45 | 'trim_empty_lines', 46 | ) 47 | 48 | 49 | def compact(text, *args, **kw): 50 | ''' 51 | Compact whitespace in a string. 52 | 53 | Trims leading and trailing whitespace, replaces runs of whitespace 54 | characters with a single space and interpolates any arguments using 55 | :func:`format()`. 56 | 57 | :param text: The text to compact (a string). 58 | :param args: Any positional arguments are interpolated using :func:`format()`. 59 | :param kw: Any keyword arguments are interpolated using :func:`format()`. 60 | :returns: The compacted text (a string). 61 | 62 | Here's an example of how I like to use the :func:`compact()` function, this 63 | is an example from a random unrelated project I'm working on at the moment:: 64 | 65 | raise PortDiscoveryError(compact(""" 66 | Failed to discover port(s) that Apache is listening on! 67 | Maybe I'm parsing the wrong configuration file? ({filename}) 68 | """, filename=self.ports_config)) 69 | 70 | The combination of :func:`compact()` and Python's multi line strings allows 71 | me to write long text fragments with interpolated variables that are easy 72 | to write, easy to read and work well with Python's whitespace 73 | sensitivity. 74 | ''' 75 | non_whitespace_tokens = text.split() 76 | compacted_text = ' '.join(non_whitespace_tokens) 77 | return format(compacted_text, *args, **kw) 78 | 79 | 80 | def compact_empty_lines(text): 81 | """ 82 | Replace repeating empty lines with a single empty line (similar to ``cat -s``). 83 | 84 | :param text: The text in which to compact empty lines (a string). 85 | :returns: The text with empty lines compacted (a string). 86 | """ 87 | i = 0 88 | lines = text.splitlines(True) 89 | while i < len(lines): 90 | if i > 0 and is_empty_line(lines[i - 1]) and is_empty_line(lines[i]): 91 | lines.pop(i) 92 | else: 93 | i += 1 94 | return ''.join(lines) 95 | 96 | 97 | def concatenate(items, conjunction='and', serial_comma=False): 98 | """ 99 | Concatenate a list of items in a human friendly way. 100 | 101 | :param items: 102 | 103 | A sequence of strings. 104 | 105 | :param conjunction: 106 | 107 | The word to use before the last item (a string, defaults to "and"). 108 | 109 | :param serial_comma: 110 | 111 | :data:`True` to use a `serial comma`_, :data:`False` otherwise 112 | (defaults to :data:`False`). 113 | 114 | :returns: 115 | 116 | A single string. 117 | 118 | >>> from humanfriendly.text import concatenate 119 | >>> concatenate(["eggs", "milk", "bread"]) 120 | 'eggs, milk and bread' 121 | 122 | .. _serial comma: https://en.wikipedia.org/wiki/Serial_comma 123 | """ 124 | items = list(items) 125 | if len(items) > 1: 126 | final_item = items.pop() 127 | formatted = ', '.join(items) 128 | if serial_comma: 129 | formatted += ',' 130 | return ' '.join([formatted, conjunction, final_item]) 131 | elif items: 132 | return items[0] 133 | else: 134 | return '' 135 | 136 | 137 | def dedent(text, *args, **kw): 138 | """ 139 | Dedent a string (remove common leading whitespace from all lines). 140 | 141 | Removes common leading whitespace from all lines in the string using 142 | :func:`textwrap.dedent()`, removes leading and trailing empty lines using 143 | :func:`trim_empty_lines()` and interpolates any arguments using 144 | :func:`format()`. 145 | 146 | :param text: The text to dedent (a string). 147 | :param args: Any positional arguments are interpolated using :func:`format()`. 148 | :param kw: Any keyword arguments are interpolated using :func:`format()`. 149 | :returns: The dedented text (a string). 150 | 151 | The :func:`compact()` function's documentation contains an example of how I 152 | like to use the :func:`compact()` and :func:`dedent()` functions. The main 153 | difference is that I use :func:`compact()` for text that will be presented 154 | to the user (where whitespace is not so significant) and :func:`dedent()` 155 | for data file and code generation tasks (where newlines and indentation are 156 | very significant). 157 | """ 158 | dedented_text = textwrap.dedent(text) 159 | trimmed_text = trim_empty_lines(dedented_text) 160 | return format(trimmed_text, *args, **kw) 161 | 162 | 163 | def format(text, *args, **kw): 164 | """ 165 | Format a string using the string formatting operator and/or :meth:`str.format()`. 166 | 167 | :param text: The text to format (a string). 168 | :param args: Any positional arguments are interpolated into the text using 169 | the string formatting operator (``%``). If no positional 170 | arguments are given no interpolation is done. 171 | :param kw: Any keyword arguments are interpolated into the text using the 172 | :meth:`str.format()` function. If no keyword arguments are given 173 | no interpolation is done. 174 | :returns: The text with any positional and/or keyword arguments 175 | interpolated (a string). 176 | 177 | The implementation of this function is so trivial that it seems silly to 178 | even bother writing and documenting it. Justifying this requires some 179 | context :-). 180 | 181 | **Why format() instead of the string formatting operator?** 182 | 183 | For really simple string interpolation Python's string formatting operator 184 | is ideal, but it does have some strange quirks: 185 | 186 | - When you switch from interpolating a single value to interpolating 187 | multiple values you have to wrap them in tuple syntax. Because 188 | :func:`format()` takes a `variable number of arguments`_ it always 189 | receives a tuple (which saves me a context switch :-). Here's an 190 | example: 191 | 192 | >>> from humanfriendly.text import format 193 | >>> # The string formatting operator. 194 | >>> print('the magic number is %s' % 42) 195 | the magic number is 42 196 | >>> print('the magic numbers are %s and %s' % (12, 42)) 197 | the magic numbers are 12 and 42 198 | >>> # The format() function. 199 | >>> print(format('the magic number is %s', 42)) 200 | the magic number is 42 201 | >>> print(format('the magic numbers are %s and %s', 12, 42)) 202 | the magic numbers are 12 and 42 203 | 204 | - When you interpolate a single value and someone accidentally passes in a 205 | tuple your code raises a :exc:`~exceptions.TypeError`. Because 206 | :func:`format()` takes a `variable number of arguments`_ it always 207 | receives a tuple so this can never happen. Here's an example: 208 | 209 | >>> # How expecting to interpolate a single value can fail. 210 | >>> value = (12, 42) 211 | >>> print('the magic value is %s' % value) 212 | Traceback (most recent call last): 213 | File "", line 1, in 214 | TypeError: not all arguments converted during string formatting 215 | >>> # The following line works as intended, no surprises here! 216 | >>> print(format('the magic value is %s', value)) 217 | the magic value is (12, 42) 218 | 219 | **Why format() instead of the str.format() method?** 220 | 221 | When you're doing complex string interpolation the :meth:`str.format()` 222 | function results in more readable code, however I frequently find myself 223 | adding parentheses to force evaluation order. The :func:`format()` function 224 | avoids this because of the relative priority between the comma and dot 225 | operators. Here's an example: 226 | 227 | >>> "{adjective} example" + " " + "(can't think of anything less {adjective})".format(adjective='silly') 228 | "{adjective} example (can't think of anything less silly)" 229 | >>> ("{adjective} example" + " " + "(can't think of anything less {adjective})").format(adjective='silly') 230 | "silly example (can't think of anything less silly)" 231 | >>> format("{adjective} example" + " " + "(can't think of anything less {adjective})", adjective='silly') 232 | "silly example (can't think of anything less silly)" 233 | 234 | The :func:`compact()` and :func:`dedent()` functions are wrappers that 235 | combine :func:`format()` with whitespace manipulation to make it easy to 236 | write nice to read Python code. 237 | 238 | .. _variable number of arguments: https://docs.python.org/2/tutorial/controlflow.html#arbitrary-argument-lists 239 | """ 240 | if args: 241 | text %= args 242 | if kw: 243 | text = text.format(**kw) 244 | return text 245 | 246 | 247 | def generate_slug(text, delimiter="-"): 248 | """ 249 | Convert text to a normalized "slug" without whitespace. 250 | 251 | :param text: The original text, for example ``Some Random Text!``. 252 | :param delimiter: The delimiter used to separate words 253 | (defaults to the ``-`` character). 254 | :returns: The slug text, for example ``some-random-text``. 255 | :raises: :exc:`~exceptions.ValueError` when the provided 256 | text is nonempty but results in an empty slug. 257 | """ 258 | slug = text.lower() 259 | escaped = delimiter.replace("\\", "\\\\") 260 | slug = re.sub("[^a-z0-9]+", escaped, slug) 261 | slug = slug.strip(delimiter) 262 | if text and not slug: 263 | msg = "The provided text %r results in an empty slug!" 264 | raise ValueError(format(msg, text)) 265 | return slug 266 | 267 | 268 | def is_empty_line(text): 269 | """ 270 | Check if a text is empty or contains only whitespace. 271 | 272 | :param text: The text to check for "emptiness" (a string). 273 | :returns: :data:`True` if the text is empty or contains only whitespace, 274 | :data:`False` otherwise. 275 | """ 276 | return len(text) == 0 or text.isspace() 277 | 278 | 279 | def join_lines(text): 280 | """ 281 | Remove "hard wrapping" from the paragraphs in a string. 282 | 283 | :param text: The text to reformat (a string). 284 | :returns: The text without hard wrapping (a string). 285 | 286 | This function works by removing line breaks when the last character before 287 | a line break and the first character after the line break are both 288 | non-whitespace characters. This means that common leading indentation will 289 | break :func:`join_lines()` (in that case you can use :func:`dedent()` 290 | before calling :func:`join_lines()`). 291 | """ 292 | return re.sub(r'(\S)\n(\S)', r'\1 \2', text) 293 | 294 | 295 | def pluralize(count, singular, plural=None): 296 | """ 297 | Combine a count with the singular or plural form of a word. 298 | 299 | :param count: The count (a number). 300 | :param singular: The singular form of the word (a string). 301 | :param plural: The plural form of the word (a string or :data:`None`). 302 | :returns: The count and singular or plural word concatenated (a string). 303 | 304 | See :func:`pluralize_raw()` for the logic underneath :func:`pluralize()`. 305 | """ 306 | return '%s %s' % (count, pluralize_raw(count, singular, plural)) 307 | 308 | 309 | def pluralize_raw(count, singular, plural=None): 310 | """ 311 | Select the singular or plural form of a word based on a count. 312 | 313 | :param count: The count (a number). 314 | :param singular: The singular form of the word (a string). 315 | :param plural: The plural form of the word (a string or :data:`None`). 316 | :returns: The singular or plural form of the word (a string). 317 | 318 | When the given count is exactly 1.0 the singular form of the word is 319 | selected, in all other cases the plural form of the word is selected. 320 | 321 | If the plural form of the word is not provided it is obtained by 322 | concatenating the singular form of the word with the letter "s". Of course 323 | this will not always be correct, which is why you have the option to 324 | specify both forms. 325 | """ 326 | if not plural: 327 | plural = singular + 's' 328 | return singular if float(count) == 1.0 else plural 329 | 330 | 331 | def random_string(length=(25, 100), characters=string.ascii_letters): 332 | """random_string(length=(25, 100), characters=string.ascii_letters) 333 | Generate a random string. 334 | 335 | :param length: The length of the string to be generated (a number or a 336 | tuple with two numbers). If this is a tuple then a random 337 | number between the two numbers given in the tuple is used. 338 | :param characters: The characters to be used (a string, defaults 339 | to :data:`string.ascii_letters`). 340 | :returns: A random string. 341 | 342 | The :func:`random_string()` function is very useful in test suites; by the 343 | time I included it in :mod:`humanfriendly.text` I had already included 344 | variants of this function in seven different test suites :-). 345 | """ 346 | if not isinstance(length, numbers.Number): 347 | length = random.randint(length[0], length[1]) 348 | return ''.join(random.choice(characters) for _ in range(length)) 349 | 350 | 351 | def split(text, delimiter=','): 352 | """ 353 | Split a comma-separated list of strings. 354 | 355 | :param text: The text to split (a string). 356 | :param delimiter: The delimiter to split on (a string). 357 | :returns: A list of zero or more nonempty strings. 358 | 359 | Here's the default behavior of Python's built in :meth:`str.split()` 360 | function: 361 | 362 | >>> 'foo,bar, baz,'.split(',') 363 | ['foo', 'bar', ' baz', ''] 364 | 365 | In contrast here's the default behavior of the :func:`split()` function: 366 | 367 | >>> from humanfriendly.text import split 368 | >>> split('foo,bar, baz,') 369 | ['foo', 'bar', 'baz'] 370 | 371 | Here is an example that parses a nested data structure (a mapping of 372 | logging level names to one or more styles per level) that's encoded in a 373 | string so it can be set as an environment variable: 374 | 375 | >>> from pprint import pprint 376 | >>> encoded_data = 'debug=green;warning=yellow;error=red;critical=red,bold' 377 | >>> parsed_data = dict((k, split(v, ',')) for k, v in (split(kv, '=') for kv in split(encoded_data, ';'))) 378 | >>> pprint(parsed_data) 379 | {'debug': ['green'], 380 | 'warning': ['yellow'], 381 | 'error': ['red'], 382 | 'critical': ['red', 'bold']} 383 | """ 384 | return [token.strip() for token in text.split(delimiter) if token and not token.isspace()] 385 | 386 | 387 | def split_paragraphs(text): 388 | """ 389 | Split a string into paragraphs (one or more lines delimited by an empty line). 390 | 391 | :param text: The text to split into paragraphs (a string). 392 | :returns: A list of strings. 393 | """ 394 | paragraphs = [] 395 | for chunk in text.split('\n\n'): 396 | chunk = trim_empty_lines(chunk) 397 | if chunk and not chunk.isspace(): 398 | paragraphs.append(chunk) 399 | return paragraphs 400 | 401 | 402 | def tokenize(text): 403 | """ 404 | Tokenize a text into numbers and strings. 405 | 406 | :param text: The text to tokenize (a string). 407 | :returns: A list of strings and/or numbers. 408 | 409 | This function is used to implement robust tokenization of user input in 410 | functions like :func:`.parse_size()` and :func:`.parse_timespan()`. It 411 | automatically coerces integer and floating point numbers, ignores 412 | whitespace and knows how to separate numbers from strings even without 413 | whitespace. Some examples to make this more concrete: 414 | 415 | >>> from humanfriendly.text import tokenize 416 | >>> tokenize('42') 417 | [42] 418 | >>> tokenize('42MB') 419 | [42, 'MB'] 420 | >>> tokenize('42.5MB') 421 | [42.5, 'MB'] 422 | >>> tokenize('42.5 MB') 423 | [42.5, 'MB'] 424 | """ 425 | tokenized_input = [] 426 | for token in re.split(r'(\d+(?:\.\d+)?)', text): 427 | token = token.strip() 428 | if re.match(r'\d+\.\d+', token): 429 | tokenized_input.append(float(token)) 430 | elif token.isdigit(): 431 | tokenized_input.append(int(token)) 432 | elif token: 433 | tokenized_input.append(token) 434 | return tokenized_input 435 | 436 | 437 | def trim_empty_lines(text): 438 | """ 439 | Trim leading and trailing empty lines from the given text. 440 | 441 | :param text: The text to trim (a string). 442 | :returns: The trimmed text (a string). 443 | """ 444 | lines = text.splitlines(True) 445 | while lines and is_empty_line(lines[0]): 446 | lines.pop(0) 447 | while lines and is_empty_line(lines[-1]): 448 | lines.pop(-1) 449 | return ''.join(lines) 450 | -------------------------------------------------------------------------------- /humanfriendly/usage.py: -------------------------------------------------------------------------------- 1 | # Human friendly input/output in Python. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: June 11, 2021 5 | # URL: https://humanfriendly.readthedocs.io 6 | 7 | """ 8 | Parsing and reformatting of usage messages. 9 | 10 | The :mod:`~humanfriendly.usage` module parses and reformats usage messages: 11 | 12 | - The :func:`format_usage()` function takes a usage message and inserts ANSI 13 | escape sequences that highlight items of special significance like command 14 | line options, meta variables, etc. The resulting usage message is (intended 15 | to be) easier to read on a terminal. 16 | 17 | - The :func:`render_usage()` function takes a usage message and rewrites it to 18 | reStructuredText_ suitable for inclusion in the documentation of a Python 19 | package. This provides a DRY solution to keeping a single authoritative 20 | definition of the usage message while making it easily available in 21 | documentation. As a cherry on the cake it's not just a pre-formatted dump of 22 | the usage message but a nicely formatted reStructuredText_ fragment. 23 | 24 | - The remaining functions in this module support the two functions above. 25 | 26 | Usage messages in general are free format of course, however the functions in 27 | this module assume a certain structure from usage messages in order to 28 | successfully parse and reformat them, refer to :func:`parse_usage()` for 29 | details. 30 | 31 | .. _DRY: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself 32 | .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText 33 | """ 34 | 35 | # Standard library modules. 36 | import csv 37 | import functools 38 | import logging 39 | import re 40 | 41 | # Standard library module or external dependency (see setup.py). 42 | from importlib import import_module 43 | 44 | # Modules included in our package. 45 | from humanfriendly.compat import StringIO 46 | from humanfriendly.text import dedent, split_paragraphs, trim_empty_lines 47 | 48 | # Public identifiers that require documentation. 49 | __all__ = ( 50 | 'find_meta_variables', 51 | 'format_usage', 52 | 'import_module', # previously exported (backwards compatibility) 53 | 'inject_usage', 54 | 'parse_usage', 55 | 'render_usage', 56 | 'USAGE_MARKER', 57 | ) 58 | 59 | USAGE_MARKER = "Usage:" 60 | """The string that starts the first line of a usage message.""" 61 | 62 | START_OF_OPTIONS_MARKER = "Supported options:" 63 | """The string that marks the start of the documented command line options.""" 64 | 65 | # Compiled regular expression used to tokenize usage messages. 66 | USAGE_PATTERN = re.compile(r''' 67 | # Make sure whatever we're matching isn't preceded by a non-whitespace 68 | # character. 69 | (?= 2.6.0 3 | flake8-docstrings >= 0.2.8 4 | pyflakes >= 1.2.3 5 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | # Test suite requirements. 2 | capturer >= 2.1 3 | coloredlogs >= 15.0.1 4 | docutils >= 0.15 5 | mock >= 3.0.5 6 | pytest >= 3.0.7 7 | pytest-cov >= 2.4.0 8 | -------------------------------------------------------------------------------- /requirements-travis.txt: -------------------------------------------------------------------------------- 1 | --requirement=requirements-checks.txt 2 | --requirement=requirements-tests.txt 3 | coveralls 4 | # Dependency of coveralls: 5 | cryptography < 3; python_version == '2.7' and platform_python_implementation == "PyPy" 6 | -------------------------------------------------------------------------------- /scripts/travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Even though Travis CI supports Mac OS X [1] and several Python interpreters 4 | # are installed out of the box, the Python environment cannot be configured in 5 | # the Travis CI build configuration [2]. 6 | # 7 | # As a workaround the build configuration file specifies a single Mac OS X job 8 | # with `language: generic' that runs this script to create and activate a 9 | # Python virtual environment. 10 | # 11 | # Recently the `virtualenv' command seems to no longer come pre-installed on 12 | # the MacOS workers of Travis CI [3] so when this situation is detected we 13 | # install it ourselves. 14 | # 15 | # [1] https://github.com/travis-ci/travis-ci/issues/216 16 | # [2] https://github.com/travis-ci/travis-ci/issues/2312 17 | # [3] https://travis-ci.org/xolox/python-humanfriendly/jobs/411396506 18 | 19 | main () { 20 | if [ "$TRAVIS_OS_NAME" = osx ]; then 21 | local environment="$HOME/virtualenv/python2.7" 22 | if [ -x "$environment/bin/python" ]; then 23 | msg "Activating virtual environment ($environment) .." 24 | source "$environment/bin/activate" 25 | else 26 | if ! which virtualenv &>/dev/null; then 27 | msg "Installing 'virtualenv' in per-user site-packages .." 28 | pip install --user virtualenv 29 | msg "Figuring out 'bin' directory of per-user site-packages .." 30 | LOCAL_BINARIES=$(python -c 'import os, site; print(os.path.join(site.USER_BASE, "bin"))') 31 | msg "Prefixing '$LOCAL_BINARIES' to PATH .." 32 | export PATH="$LOCAL_BINARIES:$PATH" 33 | fi 34 | msg "Creating virtual environment ($environment) .." 35 | virtualenv "$environment" 36 | msg "Activating virtual environment ($environment) .." 37 | source "$environment/bin/activate" 38 | msg "Checking if 'pip' executable works .." 39 | if ! pip --version; then 40 | msg "Bootstrapping working 'pip' installation using get-pip.py .." 41 | curl -s https://bootstrap.pypa.io/get-pip.py | python - 42 | fi 43 | fi 44 | fi 45 | msg "Running command: $*" 46 | eval "$@" 47 | } 48 | 49 | msg () { 50 | echo "[travis.sh] $*" >&2 51 | } 52 | 53 | main "$@" 54 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Enable building of universal wheels so we can publish wheel 2 | # distribution archives to PyPI (the Python package index) 3 | # that are compatible with Python 2 as well as Python 3. 4 | 5 | [bdist_wheel] 6 | universal=1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Setup script for the `humanfriendly' package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: September 17, 2021 7 | # URL: https://humanfriendly.readthedocs.io 8 | 9 | """ 10 | Setup script for the `humanfriendly` package. 11 | 12 | **python setup.py install** 13 | Install from the working directory into the current Python environment. 14 | 15 | **python setup.py sdist** 16 | Build a source distribution archive. 17 | 18 | **python setup.py bdist_wheel** 19 | Build a wheel distribution archive. 20 | """ 21 | 22 | # Standard library modules. 23 | import codecs 24 | import os 25 | import re 26 | import sys 27 | 28 | # De-facto standard solution for Python packaging. 29 | from setuptools import find_packages, setup 30 | 31 | 32 | def get_contents(*args): 33 | """Get the contents of a file relative to the source distribution directory.""" 34 | with codecs.open(get_absolute_path(*args), 'r', 'UTF-8') as handle: 35 | return handle.read() 36 | 37 | 38 | def get_version(*args): 39 | """Extract the version number from a Python module.""" 40 | contents = get_contents(*args) 41 | metadata = dict(re.findall('__([a-z]+)__ = [\'"]([^\'"]+)', contents)) 42 | return metadata['version'] 43 | 44 | 45 | def get_install_requires(): 46 | """Get the conditional dependencies for source distributions.""" 47 | install_requires = [] 48 | if 'bdist_wheel' not in sys.argv: 49 | if sys.version_info.major == 2: 50 | install_requires.append('monotonic') 51 | if sys.platform == 'win32': 52 | # For details about these two conditional requirements please 53 | # see https://github.com/xolox/python-humanfriendly/pull/45. 54 | install_requires.append('pyreadline ; python_version < "3.8"') 55 | install_requires.append('pyreadline3 ; python_version >= "3.8"') 56 | return sorted(install_requires) 57 | 58 | 59 | def get_extras_require(): 60 | """Get the conditional dependencies for wheel distributions.""" 61 | extras_require = {} 62 | if have_environment_marker_support(): 63 | # Conditional 'monotonic' dependency. 64 | extras_require[':python_version == "2.7"'] = ['monotonic'] 65 | # Conditional 'pyreadline' or 'pyreadline3' dependency. 66 | extras_require[':sys_platform == "win32" and python_version<"3.8"'] = 'pyreadline' 67 | extras_require[':sys_platform == "win32" and python_version>="3.8"'] = 'pyreadline3' 68 | return extras_require 69 | 70 | 71 | def get_absolute_path(*args): 72 | """Transform relative pathnames into absolute pathnames.""" 73 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 74 | 75 | 76 | def have_environment_marker_support(): 77 | """ 78 | Check whether setuptools has support for PEP-426 environment marker support. 79 | 80 | Based on the ``setup.py`` script of the ``pytest`` package: 81 | https://bitbucket.org/pytest-dev/pytest/src/default/setup.py 82 | """ 83 | try: 84 | from pkg_resources import parse_version 85 | from setuptools import __version__ 86 | return parse_version(__version__) >= parse_version('0.7.2') 87 | except Exception: 88 | return False 89 | 90 | 91 | setup( 92 | name='humanfriendly', 93 | version=get_version('humanfriendly', '__init__.py'), 94 | description="Human friendly output for text interfaces using Python", 95 | long_description=get_contents('README.rst'), 96 | url='https://humanfriendly.readthedocs.io', 97 | author="Peter Odding", 98 | author_email='peter@peterodding.com', 99 | license='MIT', 100 | packages=find_packages(), 101 | entry_points=dict(console_scripts=[ 102 | 'humanfriendly = humanfriendly.cli:main', 103 | ]), 104 | install_requires=get_install_requires(), 105 | extras_require=get_extras_require(), 106 | test_suite='humanfriendly.tests', 107 | tests_require=[ 108 | 'capturer >= 2.1', 109 | 'coloredlogs >= 2.0', 110 | ], 111 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', 112 | classifiers=[ 113 | 'Development Status :: 6 - Mature', 114 | 'Environment :: Console', 115 | 'Framework :: Sphinx :: Extension', 116 | 'Intended Audience :: Developers', 117 | 'Intended Audience :: System Administrators', 118 | 'License :: OSI Approved :: MIT License', 119 | 'Natural Language :: English', 120 | 'Programming Language :: Python', 121 | 'Programming Language :: Python :: 2', 122 | 'Programming Language :: Python :: 2.7', 123 | 'Programming Language :: Python :: 3', 124 | 'Programming Language :: Python :: 3.5', 125 | 'Programming Language :: Python :: 3.6', 126 | 'Programming Language :: Python :: 3.7', 127 | 'Programming Language :: Python :: 3.8', 128 | 'Programming Language :: Python :: 3.9', 129 | 'Programming Language :: Python :: Implementation :: CPython', 130 | 'Programming Language :: Python :: Implementation :: PyPy', 131 | 'Topic :: Communications', 132 | 'Topic :: Scientific/Engineering :: Human Machine Interfaces', 133 | 'Topic :: Software Development', 134 | 'Topic :: Software Development :: Libraries :: Python Modules', 135 | 'Topic :: Software Development :: User Interfaces', 136 | 'Topic :: System :: Shells', 137 | 'Topic :: System :: System Shells', 138 | 'Topic :: System :: Systems Administration', 139 | 'Topic :: Terminals', 140 | 'Topic :: Text Processing :: General', 141 | 'Topic :: Text Processing :: Linguistic', 142 | 'Topic :: Utilities', 143 | ]) 144 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests in multiple 2 | # virtualenvs. This configuration file will run the test suite on all supported 3 | # python versions. To use it, "pip install tox" and then run "tox" from this 4 | # directory. 5 | 6 | [tox] 7 | envlist = py27, py35, py36, py37, py38, py39, pypy 8 | 9 | [testenv] 10 | deps = -rrequirements-tests.txt 11 | commands = py.test {posargs} 12 | passenv = HOME 13 | 14 | [pytest] 15 | addopts = --verbose 16 | norecursedirs = .tox 17 | python_files = humanfriendly/tests.py 18 | 19 | [flake8] 20 | exclude = .tox 21 | extend-ignore = D200,D205,D211,D400,D401,D402,D412,D413,W504 22 | max-line-length = 120 23 | --------------------------------------------------------------------------------