From 57bc0d56d7e741e1e99d96f14e7ab93232440a16 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 30 Jan 2021 09:13:47 +0100 Subject: Adding upstream version 2.1.0. Signed-off-by: Daniel Baumann --- .github/PULL_REQUEST_TEMPLATE.md | 9 + .gitignore | 13 + .travis.yml | 30 ++ .travis/install.sh | 25 ++ AUTHORS | 29 ++ CHANGELOG | 133 ++++++++ CONTRIBUTING.rst | 102 +++++++ LICENSE | 27 ++ MANIFEST.in | 8 + README.rst | 38 +++ appveyor.yml | 15 + cli_helpers/__init__.py | 1 + cli_helpers/compat.py | 42 +++ cli_helpers/config.py | 270 +++++++++++++++++ cli_helpers/tabular_output/__init__.py | 13 + .../tabular_output/delimited_output_adapter.py | 48 +++ cli_helpers/tabular_output/output_formatter.py | 228 ++++++++++++++ cli_helpers/tabular_output/preprocessors.py | 300 ++++++++++++++++++ cli_helpers/tabular_output/tabulate_adapter.py | 99 ++++++ .../tabular_output/terminaltables_adapter.py | 97 ++++++ cli_helpers/tabular_output/tsv_output_adapter.py | 16 + .../tabular_output/vertical_table_adapter.py | 66 ++++ cli_helpers/utils.py | 106 +++++++ docs/Makefile | 20 ++ docs/source/api.rst | 23 ++ docs/source/authors.rst | 1 + docs/source/changelog.rst | 1 + docs/source/conf.py | 200 ++++++++++++ docs/source/contributing.rst | 1 + docs/source/index.rst | 30 ++ docs/source/license.rst | 13 + docs/source/quickstart.rst | 153 ++++++++++ release.py | 135 +++++++++ requirements-dev.txt | 10 + setup.cfg | 13 + setup.py | 62 ++++ tasks.py | 122 ++++++++ tests/__init__.py | 0 tests/compat.py | 78 +++++ tests/config_data/configrc | 18 ++ tests/config_data/configspecrc | 20 ++ tests/config_data/invalid_configrc | 18 ++ tests/config_data/invalid_configspecrc | 20 ++ tests/tabular_output/__init__.py | 0 .../test_delimited_output_adapter.py | 48 +++ tests/tabular_output/test_output_formatter.py | 173 +++++++++++ tests/tabular_output/test_preprocessors.py | 334 +++++++++++++++++++++ tests/tabular_output/test_tabulate_adapter.py | 96 ++++++ .../tabular_output/test_terminaltables_adapter.py | 69 +++++ tests/tabular_output/test_tsv_output_adapter.py | 33 ++ .../tabular_output/test_vertical_table_adapter.py | 38 +++ tests/test_cli_helpers.py | 5 + tests/test_config.py | 271 +++++++++++++++++ tests/test_utils.py | 70 +++++ tests/utils.py | 16 + tox.ini | 59 ++++ 56 files changed, 3865 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100755 .travis/install.sh create mode 100644 AUTHORS create mode 100644 CHANGELOG create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 appveyor.yml create mode 100644 cli_helpers/__init__.py create mode 100644 cli_helpers/compat.py create mode 100644 cli_helpers/config.py create mode 100644 cli_helpers/tabular_output/__init__.py create mode 100644 cli_helpers/tabular_output/delimited_output_adapter.py create mode 100644 cli_helpers/tabular_output/output_formatter.py create mode 100644 cli_helpers/tabular_output/preprocessors.py create mode 100644 cli_helpers/tabular_output/tabulate_adapter.py create mode 100644 cli_helpers/tabular_output/terminaltables_adapter.py create mode 100644 cli_helpers/tabular_output/tsv_output_adapter.py create mode 100644 cli_helpers/tabular_output/vertical_table_adapter.py create mode 100644 cli_helpers/utils.py create mode 100644 docs/Makefile create mode 100644 docs/source/api.rst create mode 100644 docs/source/authors.rst create mode 100644 docs/source/changelog.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/contributing.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/license.rst create mode 100644 docs/source/quickstart.rst create mode 100644 release.py create mode 100644 requirements-dev.txt create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 tasks.py create mode 100644 tests/__init__.py create mode 100644 tests/compat.py create mode 100644 tests/config_data/configrc create mode 100644 tests/config_data/configspecrc create mode 100644 tests/config_data/invalid_configrc create mode 100644 tests/config_data/invalid_configspecrc create mode 100644 tests/tabular_output/__init__.py create mode 100644 tests/tabular_output/test_delimited_output_adapter.py create mode 100644 tests/tabular_output/test_output_formatter.py create mode 100644 tests/tabular_output/test_preprocessors.py create mode 100644 tests/tabular_output/test_tabulate_adapter.py create mode 100644 tests/tabular_output/test_terminaltables_adapter.py create mode 100644 tests/tabular_output/test_tsv_output_adapter.py create mode 100644 tests/tabular_output/test_vertical_table_adapter.py create mode 100644 tests/test_cli_helpers.py create mode 100644 tests/test_config.py create mode 100644 tests/test_utils.py create mode 100644 tests/utils.py create mode 100644 tox.ini diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c84c7b5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +## Description + + + + +## Checklist + +- [ ] I've added this contribution to the `CHANGELOG`. +- [ ] I've added my name to the `AUTHORS` file (or it's already there). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..213f266 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.egg +*.egg-info +.coverage +/.tox +/build +/docs/build +/dist +/cli_helpers.egg-info +/cli_helpers_dev +.idea/ +.cache/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f184617 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,30 @@ +sudo: false +language: python +cache: pip +install: ./.travis/install.sh +script: + - source ~/.venv/bin/activate + - tox + +matrix: + include: + - os: linux + python: 3.6 + env: TOXENV=py36 + - os: linux + python: 3.6 + env: TOXENV=noextras + - os: linux + python: 3.6 + env: TOXENV=docs + - os: linux + python: 3.6 + env: TOXENV=packaging + - os: osx + language: generic + env: TOXENV=py36 + - os: linux + python: 3.7 + env: TOXENV=py37 + dist: xenial + sudo: true diff --git a/.travis/install.sh b/.travis/install.sh new file mode 100755 index 0000000..8605c57 --- /dev/null +++ b/.travis/install.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -ex + +if [[ "$(uname -s)" == 'Darwin' ]]; then + sw_vers + + git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv + export PYENV_ROOT="$HOME/.pyenv" + export PATH="$PYENV_ROOT/bin:$PATH" + eval "$(pyenv init -)" + + case "${TOXENV}" in + py36) + pyenv install 3.6.1 + pyenv global 3.6.1 + ;; + esac + pyenv rehash +fi + +pip install virtualenv +python -m virtualenv ~/.venv +source ~/.venv/bin/activate +pip install tox diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..d537ff7 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,29 @@ +Authors +======= + +CLI Helpers is written and maintained by the following people: + +- Amjith Ramanujam +- Dick Marinus +- Irina Truong +- Thomas Roten + + +Contributors +------------ + +This project receives help from these awesome contributors: + +- Terje Røsten +- Frederic Aoustin +- Zhaolong Zhu +- Karthikeyan Singaravelan +- laixintao +- Georgy Frolov +- Michał Górny + +Thanks +------ + +This project exists because of the amazing contributors from +`pgcli `_ and `mycli `_. diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..96af796 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,133 @@ +Changelog +========= + +Version 2.1.0 +------------- + +(released on 2020-07-29) + +* Speed up ouput styling of tables. + + +Version 2.0.1 +------------- + +(released on 2020-05-27) + +* Fix newline escaping in plain-text formatters (ascii, double, github) +* Use built-in unittest.mock instead of mock. + +Version 2.0.0 +------------- + +(released on 2020-05-26) + +* Remove Python 2.7 and 3.5. +* Style config for missing value. + +Version 1.2.1 +------------- + +(released on 2019-06-09) + +* Pin Pygments to >= 2.4.0 for tests. +* Remove Python 3.4 from tests and Trove classifier. +* Add an option to skip truncating multi-line strings. +* When truncating long strings, add ellipsis. + +Version 1.2.0 +------------- + +(released on 2019-04-05) + +* Run tests on Python 3.7. +* Use twine check during packaging tests. +* Rename old tsv format to csv-tab (because it add quotes), introduce new tsv output adapter. +* Truncate long fields for tabular display. +* Return the supported table formats as unicode. +* Override tab with 4 spaces for terminal tables. + +Version 1.1.0 +------------- + +(released on 2018-10-18) + +* Adds config file reading/writing. +* Style formatted tables with Pygments (optional). + +Version 1.0.2 +------------- + +(released on 2018-04-07) + +* Copy unit test from pgcli +* Use safe float for unit test +* Move strip_ansi from tests.utils to cli_helpers.utils + +Version 1.0.1 +------------- + +(released on 2017-11-27) + +* Output all unicode for terminaltables, add unit test. + +Version 1.0.0 +------------- + +(released on 2017-10-11) + +* Output as generator +* Use backports.csv only for py2 +* Require tabulate as a dependency instead of using vendored module. +* Drop support for Python 3.3. + + +Version 0.2.3 +------------- + +(released on 2017-08-01) + +* Fix unicode error on Python 2 with newlines in output row. +* Fixes to accept iterator. + + +Version 0.2.2 +------------- + +(released on 2017-07-16) + +* Fix IndexError from being raised with uneven rows. + + +Version 0.2.1 +------------- + +(released on 2017-07-11) + +* Run tests on macOS via Travis. +* Fix unicode issues on Python 2 (csv and styling output). + + +Version 0.2.0 +------------- + +(released on 2017-06-23) + +* Make vertical table separator more customizable. +* Add format numbers preprocessor. +* Add test coverage reports. +* Add ability to pass additional preprocessors when formatting output. +* Don't install tests.tabular_output. +* Add .gitignore +* Coverage for tox tests. +* Style formatted output with Pygments (optional). +* Fix issue where tabulate can't handle ANSI escape codes in default values. +* Run tests on Windows via Appveyor. + + +Version 0.1.0 +------------- + +(released on 2017-05-01) + +* Pretty print tabular data using a variety of formatting libraries. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..04f010d --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,102 @@ +How to Contribute +================= + +CLI Helpers would love your help! We appreciate your time and always give credit. + +Development Setup +----------------- + +Ready to contribute? Here's how to set up CLI Helpers for local development. + +1. `Fork the repository `_ on GitHub. +2. Clone your fork locally:: + + $ git clone + +3. Add the official repository (``upstream``) as a remote repository:: + + $ git remote add upstream git@github.com:dbcli/cli_helpers.git + +4. Set up a `virtual environment `_ + for development:: + + $ cd cli_helpers + $ pip install virtualenv + $ virtualenv cli_helpers_dev + + We've just created a virtual environment that we'll use to install all the dependencies + and tools we need to work on CLI Helpers. Whenever you want to work on CLI Helpers, you + need to activate the virtual environment:: + + $ source cli_helpers_dev/bin/activate + + When you're done working, you can deactivate the virtual environment:: + + $ deactivate + +5. Install the dependencies and development tools:: + + $ pip install -r requirements-dev.txt + $ pip install --editable . + +6. Create a branch for your bugfix or feature based off the ``master`` branch:: + + $ git checkout -b master + +7. While you work on your bugfix or feature, be sure to pull the latest changes from ``upstream``. This ensures that your local codebase is up-to-date:: + + $ git pull upstream master + +8. When your work is ready for the CLI Helpers team to review it, push your branch to your fork:: + + $ git push origin + +9. `Create a pull request `_ + on GitHub. + + +Running the Tests +----------------- + +While you work on CLI Helpers, it's important to run the tests to make sure your code +hasn't broken any existing functionality. To run the tests, just type in:: + + $ pytest + +CLI Helpers supports Python 3.6+. You can test against multiple versions of +Python by running:: + + $ tox + +You can also measure CLI Helper's test coverage by running:: + + $ pytest --cov-report= --cov=cli_helpers + $ coverage report + + +Coding Style +------------ + +CLI Helpers requires code submissions to adhere to +`PEP 8 `_. +It's easy to check the style of your code, just run:: + + $ pep8radius master + +If you see any PEP 8 style issues, you can automatically fix them by running:: + + $ pep8radius master --in-place + +Be sure to commit and push any PEP 8 fixes. + + +Documentation +------------- + +If your work in CLI Helpers requires a documentation change or addition, you can +build the documentation by running:: + + $ make -C docs clean html + $ open docs/build/html/index.html + +That will build the documentation and open it in your web browser. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf18b7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2017, dbcli +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of dbcli nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..67df761 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include *.txt *.rst *.py +include AUTHORS CHANGELOG LICENSE +include tox.ini +recursive-include docs *.py +recursive-include docs *.rst +recursive-include docs Makefile +recursive-include tests *.py +include tests/config_data/* diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4936647 --- /dev/null +++ b/README.rst @@ -0,0 +1,38 @@ +=========== +CLI Helpers +=========== + +.. image:: https://travis-ci.org/dbcli/cli_helpers.svg?branch=master + :target: https://travis-ci.org/dbcli/cli_helpers + +.. image:: https://ci.appveyor.com/api/projects/status/37a1ri2nbcp237tr/branch/master?svg=true + :target: https://ci.appveyor.com/project/dbcli/cli-helpers + +.. image:: https://codecov.io/gh/dbcli/cli_helpers/branch/master/graph/badge.svg + :target: https://codecov.io/gh/dbcli/cli_helpers + +.. image:: https://img.shields.io/pypi/v/cli_helpers.svg?style=flat + :target: https://pypi.python.org/pypi/cli_helpers + +.. start-body + +CLI Helpers is a Python package that makes it easy to perform common tasks when +building command-line apps. It's a helper library for command-line interfaces. + +Libraries like `Click `_ and +`Python Prompt Toolkit `_ +are amazing tools that help you create quality apps. CLI Helpers complements +these libraries by wrapping up common tasks in simple interfaces. + +CLI Helpers is not focused on your app's design pattern or framework -- you can +use it on its own or in combination with other libraries. It's lightweight and +easy to extend. + +What's included in CLI Helpers? + +- Prettyprinting of tabular data with custom pre-processing +- Config file reading/writing + +.. end-body + +Read the documentation at http://cli-helpers.rtfd.io diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..68eded1 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,15 @@ +environment: + matrix: + - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python37" + +build: off + +before_test: + - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" + - pip install -r requirements-dev.txt + - pip install -e . +test_script: + - pytest --cov-report= --cov=cli_helpers + - coverage report + - codecov diff --git a/cli_helpers/__init__.py b/cli_helpers/__init__.py new file mode 100644 index 0000000..a33997d --- /dev/null +++ b/cli_helpers/__init__.py @@ -0,0 +1 @@ +__version__ = '2.1.0' diff --git a/cli_helpers/compat.py b/cli_helpers/compat.py new file mode 100644 index 0000000..3f67c62 --- /dev/null +++ b/cli_helpers/compat.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""OS and Python compatibility support.""" + +from decimal import Decimal +import sys + +PY2 = sys.version_info[0] == 2 +WIN = sys.platform.startswith('win') +MAC = sys.platform == 'darwin' + + +if PY2: + text_type = unicode + binary_type = str + long_type = long + int_types = (int, long) + + from UserDict import UserDict + from backports import csv + + from StringIO import StringIO + from itertools import izip_longest as zip_longest +else: + text_type = str + binary_type = bytes + long_type = int + int_types = (int,) + + from collections import UserDict + import csv + from io import StringIO + from itertools import zip_longest + + +HAS_PYGMENTS = True +try: + from pygments.formatters.terminal256 import Terminal256Formatter +except ImportError: + HAS_PYGMENTS = False + Terminal256Formatter = None + +float_types = (float, Decimal) diff --git a/cli_helpers/config.py b/cli_helpers/config.py new file mode 100644 index 0000000..3d6cb16 --- /dev/null +++ b/cli_helpers/config.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +"""Read and write an application's config files.""" + +from __future__ import unicode_literals +import io +import logging +import os + +from configobj import ConfigObj, ConfigObjError +from validate import ValidateError, Validator + +from .compat import MAC, text_type, UserDict, WIN + +logger = logging.getLogger(__name__) + + +class ConfigError(Exception): + """Base class for exceptions in this module.""" + pass + + +class DefaultConfigValidationError(ConfigError): + """Indicates the default config file did not validate correctly.""" + pass + + +class Config(UserDict, object): + """Config reader/writer class. + + :param str app_name: The application's name. + :param str app_author: The application author/organization. + :param str filename: The config filename to look for (e.g. ``config``). + :param dict/str default: The default config values or absolute path to + config file. + :param bool validate: Whether or not to validate the config file. + :param bool write_default: Whether or not to write the default config + file to the user config directory if it doesn't + already exist. + :param tuple additional_dirs: Additional directories to check for a config + file. + """ + + def __init__(self, app_name, app_author, filename, default=None, + validate=False, write_default=False, additional_dirs=()): + super(Config, self).__init__() + #: The :class:`ConfigObj` instance. + self.data = ConfigObj() + + self.default = {} + self.default_file = self.default_config = None + self.config_filenames = [] + + self.app_name, self.app_author = app_name, app_author + self.filename = filename + self.write_default = write_default + self.validate = validate + self.additional_dirs = additional_dirs + + if isinstance(default, dict): + self.default = default + self.update(default) + elif isinstance(default, text_type): + self.default_file = default + elif default is not None: + raise TypeError( + '"default" must be a dict or {}, not {}'.format( + text_type.__name__, type(default))) + + if self.write_default and not self.default_file: + raise ValueError('Cannot use "write_default" without specifying ' + 'a default file.') + + if self.validate and not self.default_file: + raise ValueError('Cannot use "validate" without specifying a ' + 'default file.') + + def read_default_config(self): + """Read the default config file. + + :raises DefaultConfigValidationError: There was a validation error with + the *default* file. + """ + if self.validate: + self.default_config = ConfigObj(configspec=self.default_file, + list_values=False, _inspec=True, + encoding='utf8') + valid = self.default_config.validate(Validator(), copy=True, + preserve_errors=True) + if valid is not True: + for name, section in valid.items(): + if section is True: + continue + for key, value in section.items(): + if isinstance(value, ValidateError): + raise DefaultConfigValidationError( + 'section [{}], key "{}": {}'.format( + name, key, value)) + elif self.default_file: + self.default_config, _ = self.read_config_file(self.default_file) + + self.update(self.default_config) + + def read(self): + """Read the default, additional, system, and user config files. + + :raises DefaultConfigValidationError: There was a validation error with + the *default* file. + """ + if self.default_file: + self.read_default_config() + return self.read_config_files(self.all_config_files()) + + def user_config_file(self): + """Get the absolute path to the user config file.""" + return os.path.join( + get_user_config_dir(self.app_name, self.app_author), + self.filename) + + def system_config_files(self): + """Get a list of absolute paths to the system config files.""" + return [os.path.join(f, self.filename) for f in get_system_config_dirs( + self.app_name, self.app_author)] + + def additional_files(self): + """Get a list of absolute paths to the additional config files.""" + return [os.path.join(f, self.filename) for f in self.additional_dirs] + + def all_config_files(self): + """Get a list of absolute paths to all the config files.""" + return (self.additional_files() + self.system_config_files() + + [self.user_config_file()]) + + def write_default_config(self, overwrite=False): + """Write the default config to the user's config file. + + :param bool overwrite: Write over an existing config if it exists. + """ + destination = self.user_config_file() + if not overwrite and os.path.exists(destination): + return + + with io.open(destination, mode='wb') as f: + self.default_config.write(f) + + def write(self, outfile=None, section=None): + """Write the current config to a file (defaults to user config). + + :param str outfile: The path to the file to write to. + :param None/str section: The config section to write, or :data:`None` + to write the entire config. + """ + with io.open(outfile or self.user_config_file(), 'wb') as f: + self.data.write(outfile=f, section=section) + + def read_config_file(self, f): + """Read a config file *f*. + + :param str f: The path to a file to read. + """ + configspec = self.default_file if self.validate else None + try: + config = ConfigObj(infile=f, configspec=configspec, + interpolation=False, encoding='utf8') + except ConfigObjError as e: + logger.warning( + 'Unable to parse line {} of config file {}'.format( + e.line_number, f)) + config = e.config + + valid = True + if self.validate: + valid = config.validate(Validator(), preserve_errors=True, + copy=True) + if bool(config): + self.config_filenames.append(config.filename) + + return config, valid + + def read_config_files(self, files): + """Read a list of config files. + + :param iterable files: An iterable (e.g. list) of files to read. + """ + errors = {} + for _file in files: + config, valid = self.read_config_file(_file) + self.update(config) + if valid is not True: + errors[_file] = valid + return errors or True + + +def get_user_config_dir(app_name, app_author, roaming=True, force_xdg=True): + """Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + For an example application called ``"My App"`` by ``"Acme"``, + something like the following folders could be returned: + + macOS (non-XDG): + ``~/Library/Application Support/My App`` + Mac OS X (XDG): + ``~/.config/my-app`` + Unix: + ``~/.config/my-app`` + Windows 7 (roaming): + ``C:\\Users\\\\AppData\\Roaming\\Acme\\My App`` + Windows 7 (not roaming): + ``C:\\Users\\\\AppData\\Local\\Acme\\My App`` + + :param app_name: the application name. This should be properly capitalized + and can contain whitespace. + :param app_author: The app author's name (or company). This should be + properly capitalized and can contain whitespace. + :param roaming: controls if the folder should be roaming or not on Windows. + Has no effect on non-Windows systems. + :param force_xdg: if this is set to `True`, then on macOS the XDG Base + Directory Specification will be followed. Has no effect + on non-macOS systems. + + """ + if WIN: + key = 'APPDATA' if roaming else 'LOCALAPPDATA' + folder = os.path.expanduser(os.environ.get(key, '~')) + return os.path.join(folder, app_author, app_name) + if MAC and not force_xdg: + return os.path.join(os.path.expanduser( + '~/Library/Application Support'), app_name) + return os.path.join( + os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config')), + _pathify(app_name)) + + +def get_system_config_dirs(app_name, app_author, force_xdg=True): + r"""Returns a list of system-wide config folders for the application. + + For an example application called ``"My App"`` by ``"Acme"``, + something like the following folders could be returned: + + macOS (non-XDG): + ``['/Library/Application Support/My App']`` + Mac OS X (XDG): + ``['/etc/xdg/my-app']`` + Unix: + ``['/etc/xdg/my-app']`` + Windows 7: + ``['C:\ProgramData\Acme\My App']`` + + :param app_name: the application name. This should be properly capitalized + and can contain whitespace. + :param app_author: The app author's name (or company). This should be + properly capitalized and can contain whitespace. + :param force_xdg: if this is set to `True`, then on macOS the XDG Base + Directory Specification will be followed. Has no effect + on non-macOS systems. + + """ + if WIN: + folder = os.environ.get('PROGRAMDATA') + return [os.path.join(folder, app_author, app_name)] + if MAC and not force_xdg: + return [os.path.join('/Library/Application Support', app_name)] + dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg') + paths = [os.path.expanduser(x) for x in dirs.split(os.pathsep)] + return [os.path.join(d, _pathify(app_name)) for d in paths] + + +def _pathify(s): + """Convert spaces to hyphens and lowercase a string.""" + return '-'.join(s.split()).lower() diff --git a/cli_helpers/tabular_output/__init__.py b/cli_helpers/tabular_output/__init__.py new file mode 100644 index 0000000..de2f62f --- /dev/null +++ b/cli_helpers/tabular_output/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""CLI Helper's tabular output module makes it easy to format your data using +various formatting libraries. + +When formatting data, you'll primarily use the +:func:`~cli_helpers.tabular_output.format_output` function and +:class:`~cli_helpers.tabular_output.TabularOutputFormatter` class. + +""" + +from .output_formatter import format_output, TabularOutputFormatter + +__all__ = ['format_output', 'TabularOutputFormatter'] diff --git a/cli_helpers/tabular_output/delimited_output_adapter.py b/cli_helpers/tabular_output/delimited_output_adapter.py new file mode 100644 index 0000000..098e528 --- /dev/null +++ b/cli_helpers/tabular_output/delimited_output_adapter.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""A delimited data output adapter (e.g. CSV, TSV).""" + +from __future__ import unicode_literals +import contextlib + +from cli_helpers.compat import csv, StringIO +from cli_helpers.utils import filter_dict_by_key +from .preprocessors import bytes_to_string, override_missing_value + +supported_formats = ('csv', 'csv-tab') +preprocessors = (override_missing_value, bytes_to_string) + + +class linewriter(object): + def __init__(self): + self.reset() + + def reset(self): + self.line = None + + def write(self, d): + self.line = d + + +def adapter(data, headers, table_format='csv', **kwargs): + """Wrap the formatting inside a function for TabularOutputFormatter.""" + keys = ('dialect', 'delimiter', 'doublequote', 'escapechar', + 'quotechar', 'quoting', 'skipinitialspace', 'strict') + if table_format == 'csv': + delimiter = ',' + elif table_format == 'csv-tab': + delimiter = '\t' + else: + raise ValueError('Invalid table_format specified.') + + ckwargs = {'delimiter': delimiter, 'lineterminator': ''} + ckwargs.update(filter_dict_by_key(kwargs, keys)) + + l = linewriter() + writer = csv.writer(l, **ckwargs) + writer.writerow(headers) + yield l.line + + for row in data: + l.reset() + writer.writerow(row) + yield l.line diff --git a/cli_helpers/tabular_output/output_formatter.py b/cli_helpers/tabular_output/output_formatter.py new file mode 100644 index 0000000..fce44b2 --- /dev/null +++ b/cli_helpers/tabular_output/output_formatter.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +"""A generic tabular data output formatter interface.""" + +from __future__ import unicode_literals +from collections import namedtuple + +from cli_helpers.compat import (text_type, binary_type, int_types, float_types, + zip_longest) +from cli_helpers.utils import unique_items +from . import (delimited_output_adapter, vertical_table_adapter, + tabulate_adapter, terminaltables_adapter, tsv_output_adapter) +from decimal import Decimal + +import itertools + +MISSING_VALUE = '' +MAX_FIELD_WIDTH = 500 + +TYPES = { + type(None): 0, + bool: 1, + int: 2, + float: 3, + Decimal: 3, + binary_type: 4, + text_type: 5 +} + +OutputFormatHandler = namedtuple( + 'OutputFormatHandler', + 'format_name preprocessors formatter formatter_args') + + +class TabularOutputFormatter(object): + """An interface to various tabular data formatting libraries. + + The formatting libraries supported include: + - `tabulate `_ + - `terminaltables `_ + - a CLI Helper vertical table layout + - delimited formats (CSV and TSV) + + :param str format_name: An optional, default format name. + + Usage:: + + >>> from cli_helpers.tabular_output import TabularOutputFormatter + >>> formatter = TabularOutputFormatter(format_name='simple') + >>> data = ((1, 87), (2, 80), (3, 79)) + >>> headers = ('day', 'temperature') + >>> print(formatter.format_output(data, headers)) + day temperature + ----- ------------- + 1 87 + 2 80 + 3 79 + + You can use any :term:`iterable` for the data or headers:: + + >>> data = enumerate(('87', '80', '79'), 1) + >>> print(formatter.format_output(data, headers)) + day temperature + ----- ------------- + 1 87 + 2 80 + 3 79 + + """ + + _output_formats = {} + + def __init__(self, format_name=None): + """Set the default *format_name*.""" + self._format_name = None + + if format_name: + self.format_name = format_name + + @property + def format_name(self): + """The current format name. + + This value must be in :data:`supported_formats`. + + """ + return self._format_name + + @format_name.setter + def format_name(self, format_name): + """Set the default format name. + + :param str format_name: The display format name. + :raises ValueError: if the format is not recognized. + + """ + if format_name in self.supported_formats: + self._format_name = format_name + else: + raise ValueError('unrecognized format_name "{}"'.format( + format_name)) + + @property + def supported_formats(self): + """The names of the supported output formats in a :class:`tuple`.""" + return tuple(self._output_formats.keys()) + + @classmethod + def register_new_formatter(cls, format_name, handler, preprocessors=(), + kwargs=None): + """Register a new output formatter. + + :param str format_name: The name of the format. + :param callable handler: The function that formats the data. + :param tuple preprocessors: The preprocessors to call before + formatting. + :param dict kwargs: Keys/values for keyword argument defaults. + + """ + cls._output_formats[format_name] = OutputFormatHandler( + format_name, preprocessors, handler, kwargs or {}) + + def format_output(self, data, headers, format_name=None, + preprocessors=(), column_types=None, **kwargs): + r"""Format the headers and data using a specific formatter. + + *format_name* must be a supported formatter (see + :attr:`supported_formats`). + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str format_name: The display format to use (optional, if the + :class:`TabularOutputFormatter` object has a default format set). + :param tuple preprocessors: Additional preprocessors to call before + any formatter preprocessors. + :param \*\*kwargs: Optional arguments for the formatter. + :return: The formatted data. + :rtype: str + :raises ValueError: If the *format_name* is not recognized. + + """ + format_name = format_name or self._format_name + if format_name not in self.supported_formats: + raise ValueError('unrecognized format "{}"'.format(format_name)) + + (_, _preprocessors, formatter, + fkwargs) = self._output_formats[format_name] + fkwargs.update(kwargs) + if column_types is None: + data = list(data) + column_types = self._get_column_types(data) + for f in unique_items(preprocessors + _preprocessors): + data, headers = f(data, headers, column_types=column_types, + **fkwargs) + return formatter(list(data), headers, column_types=column_types, **fkwargs) + + def _get_column_types(self, data): + """Get a list of the data types for each column in *data*.""" + columns = list(zip_longest(*data)) + return [self._get_column_type(column) for column in columns] + + def _get_column_type(self, column): + """Get the most generic data type for iterable *column*.""" + type_values = [TYPES[self._get_type(v)] for v in column] + inverse_types = {v: k for k, v in TYPES.items()} + return inverse_types[max(type_values)] + + def _get_type(self, value): + """Get the data type for *value*.""" + if value is None: + return type(None) + elif type(value) in int_types: + return int + elif type(value) in float_types: + return float + elif isinstance(value, binary_type): + return binary_type + else: + return text_type + + +def format_output(data, headers, format_name, **kwargs): + r"""Format output using *format_name*. + + This is a wrapper around the :class:`TabularOutputFormatter` class. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str format_name: The display format to use. + :param \*\*kwargs: Optional arguments for the formatter. + :return: The formatted data. + :rtype: str + + """ + formatter = TabularOutputFormatter(format_name=format_name) + return formatter.format_output(data, headers, **kwargs) + + +for vertical_format in vertical_table_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + vertical_format, vertical_table_adapter.adapter, + vertical_table_adapter.preprocessors, + {'table_format': vertical_format, 'missing_value': MISSING_VALUE, 'max_field_width': None}) + +for delimited_format in delimited_output_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + delimited_format, delimited_output_adapter.adapter, + delimited_output_adapter.preprocessors, + {'table_format': delimited_format, 'missing_value': '', 'max_field_width': None}) + +for tabulate_format in tabulate_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + tabulate_format, tabulate_adapter.adapter, + tabulate_adapter.preprocessors + + (tabulate_adapter.style_output_table(tabulate_format),), + {'table_format': tabulate_format, 'missing_value': MISSING_VALUE, 'max_field_width': MAX_FIELD_WIDTH}) + +for terminaltables_format in terminaltables_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + terminaltables_format, terminaltables_adapter.adapter, + terminaltables_adapter.preprocessors + + (terminaltables_adapter.style_output_table(terminaltables_format),), + {'table_format': terminaltables_format, 'missing_value': MISSING_VALUE, 'max_field_width': MAX_FIELD_WIDTH}) + +for tsv_format in tsv_output_adapter.supported_formats: + TabularOutputFormatter.register_new_formatter( + tsv_format, tsv_output_adapter.adapter, + tsv_output_adapter.preprocessors, + {'table_format': tsv_format, 'missing_value': '', 'max_field_width': None}) diff --git a/cli_helpers/tabular_output/preprocessors.py b/cli_helpers/tabular_output/preprocessors.py new file mode 100644 index 0000000..d6d09e0 --- /dev/null +++ b/cli_helpers/tabular_output/preprocessors.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +"""These preprocessor functions are used to process data prior to output.""" + +import string + +from cli_helpers import utils +from cli_helpers.compat import text_type, int_types, float_types, HAS_PYGMENTS + + +def truncate_string(data, headers, max_field_width=None, skip_multiline_string=True, **_): + """Truncate very long strings. Only needed for tabular + representation, because trying to tabulate very long data + is problematic in terms of performance, and does not make any + sense visually. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param int max_field_width: Width to truncate field for display + :return: The processed data and headers. + :rtype: tuple + """ + return (([utils.truncate_string(v, max_field_width, skip_multiline_string) for v in row] for row in data), + [utils.truncate_string(h, max_field_width, skip_multiline_string) for h in headers]) + + +def convert_to_string(data, headers, **_): + """Convert all *data* and *headers* to strings. + + Binary data that cannot be decoded is converted to a hexadecimal + representation via :func:`binascii.hexlify`. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :return: The processed data and headers. + :rtype: tuple + + """ + return (([utils.to_string(v) for v in row] for row in data), + [utils.to_string(h) for h in headers]) + + +def override_missing_value(data, headers, style=None, + missing_value_token="Token.Output.Null", + missing_value='', **_): + """Override missing values in the *data* with *missing_value*. + + A missing value is any value that is :data:`None`. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param style: Style for missing_value. + :param missing_value_token: The Pygments token used for missing data. + :param missing_value: The default value to use for missing data. + :return: The processed data and headers. + :rtype: tuple + + """ + def fields(): + for row in data: + processed = [] + for field in row: + if field is None and style and HAS_PYGMENTS: + styled = utils.style_field(missing_value_token, missing_value, style) + processed.append(styled) + elif field is None: + processed.append(missing_value) + else: + processed.append(field) + yield processed + + return (fields(), headers) + + +def override_tab_value(data, headers, new_value=' ', **_): + """Override tab values in the *data* with *new_value*. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param new_value: The new value to use for tab. + :return: The processed data and headers. + :rtype: tuple + + """ + return (([v.replace('\t', new_value) if isinstance(v, text_type) else v + for v in row] for row in data), + headers) + + +def escape_newlines(data, headers, **_): + """Escape newline characters (\n -> \\n, \r -> \\r) + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :return: The processed data and headers. + :rtype: tuple + + """ + return ( + ( + [ + v.replace("\r", r"\r").replace("\n", r"\n") + if isinstance(v, text_type) + else v + for v in row + ] + for row in data + ), + headers, + ) + + +def bytes_to_string(data, headers, **_): + """Convert all *data* and *headers* bytes to strings. + + Binary data that cannot be decoded is converted to a hexadecimal + representation via :func:`binascii.hexlify`. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :return: The processed data and headers. + :rtype: tuple + + """ + return (([utils.bytes_to_string(v) for v in row] for row in data), + [utils.bytes_to_string(h) for h in headers]) + + +def align_decimals(data, headers, column_types=(), **_): + """Align numbers in *data* on their decimal points. + + Whitespace padding is added before a number so that all numbers in a + column are aligned. + + Outputting data before aligning the decimals:: + + 1 + 2.1 + 10.59 + + Outputting data after aligning the decimals:: + + 1 + 2.1 + 10.59 + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param iterable column_types: The columns' type objects (e.g. int or float). + :return: The processed data and headers. + :rtype: tuple + + """ + pointpos = len(headers) * [0] + data = list(data) + for row in data: + for i, v in enumerate(row): + if column_types[i] is float and type(v) in float_types: + v = text_type(v) + pointpos[i] = max(utils.intlen(v), pointpos[i]) + + def results(data): + for row in data: + result = [] + for i, v in enumerate(row): + if column_types[i] is float and type(v) in float_types: + v = text_type(v) + result.append((pointpos[i] - utils.intlen(v)) * " " + v) + else: + result.append(v) + yield result + + return results(data), headers + + +def quote_whitespaces(data, headers, quotestyle="'", **_): + """Quote leading/trailing whitespace in *data*. + + When outputing data with leading or trailing whitespace, it can be useful + to put quotation marks around the value so the whitespace is more + apparent. If one value in a column needs quoted, then all values in that + column are quoted to keep things consistent. + + .. NOTE:: + :data:`string.whitespace` is used to determine which characters are + whitespace. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str quotestyle: The quotation mark to use (defaults to ``'``). + :return: The processed data and headers. + :rtype: tuple + + """ + whitespace = tuple(string.whitespace) + quote = len(headers) * [False] + data = list(data) + for row in data: + for i, v in enumerate(row): + v = text_type(v) + if v.startswith(whitespace) or v.endswith(whitespace): + quote[i] = True + + def results(data): + for row in data: + result = [] + for i, v in enumerate(row): + quotation = quotestyle if quote[i] else '' + result.append('{quotestyle}{value}{quotestyle}'.format( + quotestyle=quotation, value=v)) + yield result + return results(data), headers + + +def style_output(data, headers, style=None, + header_token='Token.Output.Header', + odd_row_token='Token.Output.OddRow', + even_row_token='Token.Output.EvenRow', **_): + """Style the *data* and *headers* (e.g. bold, italic, and colors) + + .. NOTE:: + This requires the `Pygments `_ library to + be installed. You can install it with CLI Helpers as an extra:: + $ pip install cli_helpers[styles] + + Example usage:: + + from cli_helpers.tabular_output.preprocessors import style_output + from pygments.style import Style + from pygments.token import Token + + class YourStyle(Style): + default_style = "" + styles = { + Token.Output.Header: 'bold ansibrightred', + Token.Output.OddRow: 'bg:#eee #111', + Token.Output.EvenRow: '#0f0' + } + + headers = ('First Name', 'Last Name') + data = [['Fred', 'Roberts'], ['George', 'Smith']] + + data, headers = style_output(data, headers, style=YourStyle) + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str/pygments.style.Style style: A Pygments style. You can `create + your own styles `_. + :param str header_token: The token type to be used for the headers. + :param str odd_row_token: The token type to be used for odd rows. + :param str even_row_token: The token type to be used for even rows. + :return: The styled data and headers. + :rtype: tuple + + """ + from cli_helpers.utils import filter_style_table + relevant_styles = filter_style_table(style, header_token, odd_row_token, even_row_token) + if style and HAS_PYGMENTS: + if relevant_styles.get(header_token): + headers = [utils.style_field(header_token, header, style) for header in headers] + if relevant_styles.get(odd_row_token) or relevant_styles.get(even_row_token): + data = ([utils.style_field(odd_row_token if i % 2 else even_row_token, f, style) + for f in r] for i, r in enumerate(data, 1)) + + return iter(data), headers + + +def format_numbers(data, headers, column_types=(), integer_format=None, + float_format=None, **_): + """Format numbers according to a format specification. + + This uses Python's format specification to format numbers of the following + types: :class:`int`, :class:`py2:long` (Python 2), :class:`float`, and + :class:`~decimal.Decimal`. See the :ref:`python:formatspec` for more + information about the format strings. + + .. NOTE:: + A column is only formatted if all of its values are the same type + (except for :data:`None`). + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param iterable column_types: The columns' type objects (e.g. int or float). + :param str integer_format: The format string to use for integer columns. + :param str float_format: The format string to use for float columns. + :return: The processed data and headers. + :rtype: tuple + + """ + if (integer_format is None and float_format is None) or not column_types: + return iter(data), headers + + def _format_number(field, column_type): + if integer_format and column_type is int and type(field) in int_types: + return format(field, integer_format) + elif float_format and column_type is float and type(field) in float_types: + return format(field, float_format) + return field + + data = ([_format_number(v, column_types[i]) for i, v in enumerate(row)] for row in data) + return data, headers diff --git a/cli_helpers/tabular_output/tabulate_adapter.py b/cli_helpers/tabular_output/tabulate_adapter.py new file mode 100644 index 0000000..8c335b7 --- /dev/null +++ b/cli_helpers/tabular_output/tabulate_adapter.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""Format adapter for the tabulate module.""" + +from __future__ import unicode_literals + +from cli_helpers.utils import filter_dict_by_key +from cli_helpers.compat import (Terminal256Formatter, StringIO) +from .preprocessors import (convert_to_string, truncate_string, override_missing_value, + style_output, HAS_PYGMENTS) + +import tabulate + +supported_markup_formats = ('mediawiki', 'html', 'latex', 'latex_booktabs', + 'textile', 'moinmoin', 'jira') +supported_table_formats = ('plain', 'simple', 'grid', 'fancy_grid', 'pipe', + 'orgtbl', 'psql', 'rst') +supported_formats = supported_markup_formats + supported_table_formats + +preprocessors = (override_missing_value, convert_to_string, truncate_string, style_output) + + +def style_output_table(format_name=""): + def style_output(data, headers, style=None, + table_separator_token='Token.Output.TableSeparator', **_): + """Style the *table* a(e.g. bold, italic, and colors) + + .. NOTE:: + This requires the `Pygments `_ library to + be installed. You can install it with CLI Helpers as an extra:: + $ pip install cli_helpers[styles] + + Example usage:: + + from cli_helpers.tabular_output import tabulate_adapter + from pygments.style import Style + from pygments.token import Token + + class YourStyle(Style): + default_style = "" + styles = { + Token.Output.TableSeparator: '#ansigray' + } + + headers = ('First Name', 'Last Name') + data = [['Fred', 'Roberts'], ['George', 'Smith']] + style_output_table = tabulate_adapter.style_output_table('psql') + style_output_table(data, headers, style=CliStyle) + + data, headers = style_output(data, headers, style=YourStyle) + output = tabulate_adapter.adapter(data, headers, style=YourStyle) + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str/pygments.style.Style style: A Pygments style. You can `create + your own styles `_. + :param str table_separator_token: The token type to be used for the table separator. + :return: data and headers. + :rtype: tuple + + """ + if style and HAS_PYGMENTS and format_name in supported_table_formats: + formatter = Terminal256Formatter(style=style) + + def style_field(token, field): + """Get the styled text for a *field* using *token* type.""" + s = StringIO() + formatter.format(((token, field),), s) + return s.getvalue() + + def addColorInElt(elt): + if not elt: + return elt + if elt.__class__ == tabulate.Line: + return tabulate.Line(*(style_field(table_separator_token, val) for val in elt)) + if elt.__class__ == tabulate.DataRow: + return tabulate.DataRow(*(style_field(table_separator_token, val) for val in elt)) + return elt + + srcfmt = tabulate._table_formats[format_name] + newfmt = tabulate.TableFormat( + *(addColorInElt(val) for val in srcfmt)) + tabulate._table_formats[format_name] = newfmt + + return iter(data), headers + return style_output + +def adapter(data, headers, table_format=None, preserve_whitespace=False, + **kwargs): + """Wrap tabulate inside a function for TabularOutputFormatter.""" + keys = ('floatfmt', 'numalign', 'stralign', 'showindex', 'disable_numparse') + tkwargs = {'tablefmt': table_format} + tkwargs.update(filter_dict_by_key(kwargs, keys)) + + if table_format in supported_markup_formats: + tkwargs.update(numalign=None, stralign=None) + + tabulate.PRESERVE_WHITESPACE = preserve_whitespace + + return iter(tabulate.tabulate(data, headers, **tkwargs).split('\n')) diff --git a/cli_helpers/tabular_output/terminaltables_adapter.py b/cli_helpers/tabular_output/terminaltables_adapter.py new file mode 100644 index 0000000..b9c7497 --- /dev/null +++ b/cli_helpers/tabular_output/terminaltables_adapter.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""Format adapter for the terminaltables module.""" + +from __future__ import unicode_literals + +import terminaltables +import itertools + +from cli_helpers.utils import filter_dict_by_key +from cli_helpers.compat import (Terminal256Formatter, StringIO) +from .preprocessors import (convert_to_string, truncate_string, override_missing_value, + style_output, HAS_PYGMENTS, + override_tab_value, escape_newlines) + +supported_formats = ('ascii', 'double', 'github') +preprocessors = ( + override_missing_value, convert_to_string, override_tab_value, + truncate_string, style_output, escape_newlines +) + +table_format_handler = { + 'ascii': terminaltables.AsciiTable, + 'double': terminaltables.DoubleTable, + 'github': terminaltables.GithubFlavoredMarkdownTable, +} + + +def style_output_table(format_name=""): + def style_output(data, headers, style=None, + table_separator_token='Token.Output.TableSeparator', **_): + """Style the *table* (e.g. bold, italic, and colors) + + .. NOTE:: + This requires the `Pygments `_ library to + be installed. You can install it with CLI Helpers as an extra:: + $ pip install cli_helpers[styles] + + Example usage:: + + from cli_helpers.tabular_output import terminaltables_adapter + from pygments.style import Style + from pygments.token import Token + + class YourStyle(Style): + default_style = "" + styles = { + Token.Output.TableSeparator: '#ansigray' + } + + headers = ('First Name', 'Last Name') + data = [['Fred', 'Roberts'], ['George', 'Smith']] + style_output_table = terminaltables_adapter.style_output_table('psql') + style_output_table(data, headers, style=CliStyle) + + output = terminaltables_adapter.adapter(data, headers, style=YourStyle) + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str/pygments.style.Style style: A Pygments style. You can `create + your own styles `_. + :param str table_separator_token: The token type to be used for the table separator. + :return: data and headers. + :rtype: tuple + + """ + if style and HAS_PYGMENTS and format_name in supported_formats: + formatter = Terminal256Formatter(style=style) + + def style_field(token, field): + """Get the styled text for a *field* using *token* type.""" + s = StringIO() + formatter.format(((token, field),), s) + return s.getvalue() + + clss = table_format_handler[format_name] + for char in [char for char in terminaltables.base_table.BaseTable.__dict__ if char.startswith("CHAR_")]: + setattr(clss, char, style_field( + table_separator_token, getattr(clss, char))) + + return iter(data), headers + return style_output + + +def adapter(data, headers, table_format=None, **kwargs): + """Wrap terminaltables inside a function for TabularOutputFormatter.""" + keys = ('title', ) + + table = table_format_handler[table_format] + + t = table([headers] + list(data), **filter_dict_by_key(kwargs, keys)) + + dimensions = terminaltables.width_and_alignment.max_dimensions( + t.table_data, + t.padding_left, + t.padding_right)[:3] + for r in t.gen_table(*dimensions): + yield u''.join(r) diff --git a/cli_helpers/tabular_output/tsv_output_adapter.py b/cli_helpers/tabular_output/tsv_output_adapter.py new file mode 100644 index 0000000..5cdc585 --- /dev/null +++ b/cli_helpers/tabular_output/tsv_output_adapter.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""A tsv data output adapter""" + +from __future__ import unicode_literals + +from .preprocessors import bytes_to_string, override_missing_value, convert_to_string +from itertools import chain +from cli_helpers.utils import replace + +supported_formats = ('tsv',) +preprocessors = (override_missing_value, bytes_to_string, convert_to_string) + +def adapter(data, headers, **kwargs): + """Wrap the formatting inside a function for TabularOutputFormatter.""" + for row in chain((headers,), data): + yield "\t".join((replace(r, (('\n', r'\n'), ('\t', r'\t'))) for r in row)) diff --git a/cli_helpers/tabular_output/vertical_table_adapter.py b/cli_helpers/tabular_output/vertical_table_adapter.py new file mode 100644 index 0000000..a359f7d --- /dev/null +++ b/cli_helpers/tabular_output/vertical_table_adapter.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Format data into a vertical table layout.""" + +from __future__ import unicode_literals + +from cli_helpers.utils import filter_dict_by_key +from .preprocessors import (convert_to_string, override_missing_value, + style_output) + +supported_formats = ('vertical', ) +preprocessors = (override_missing_value, convert_to_string, style_output) + + +def _get_separator(num, sep_title, sep_character, sep_length): + """Get a row separator for row *num*.""" + left_divider_length = right_divider_length = sep_length + if isinstance(sep_length, tuple): + left_divider_length, right_divider_length = sep_length + left_divider = sep_character * left_divider_length + right_divider = sep_character * right_divider_length + title = sep_title.format(n=num + 1) + + return "{left_divider}[ {title} ]{right_divider}\n".format( + left_divider=left_divider, right_divider=right_divider, title=title) + + +def _format_row(headers, row): + """Format a row.""" + formatted_row = [' | '.join(field) for field in zip(headers, row)] + return '\n'.join(formatted_row) + + +def vertical_table(data, headers, sep_title='{n}. row', sep_character='*', + sep_length=27): + """Format *data* and *headers* as an vertical table. + + The values in *data* and *headers* must be strings. + + :param iterable data: An :term:`iterable` (e.g. list) of rows. + :param iterable headers: The column headers. + :param str sep_title: The title given to each row separator. Defaults to + ``'{n}. row'``. Any instance of ``'{n}'`` is + replaced by the record number. + :param str sep_character: The character used to separate rows. Defaults to + ``'*'``. + :param int/tuple sep_length: The number of separator characters that should + appear on each side of the *sep_title*. Use + a tuple to specify the left and right values + separately. + :return: The formatted data. + :rtype: str + + """ + header_len = max([len(x) for x in headers]) + padded_headers = [x.ljust(header_len) for x in headers] + formatted_rows = [_format_row(padded_headers, row) for row in data] + + output = [] + for i, result in enumerate(formatted_rows): + yield _get_separator(i, sep_title, sep_character, sep_length) + result + + +def adapter(data, headers, **kwargs): + """Wrap vertical table in a function for TabularOutputFormatter.""" + keys = ('sep_title', 'sep_character', 'sep_length') + return vertical_table(data, headers, **filter_dict_by_key(kwargs, keys)) diff --git a/cli_helpers/utils.py b/cli_helpers/utils.py new file mode 100644 index 0000000..f11fa40 --- /dev/null +++ b/cli_helpers/utils.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""Various utility functions and helpers.""" + +import binascii +import re +from functools import lru_cache +from typing import Dict + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from pygments.style import StyleMeta + +from cli_helpers.compat import binary_type, text_type, Terminal256Formatter, StringIO + + +def bytes_to_string(b): + """Convert bytes *b* to a string. + + Hexlify bytes that can't be decoded. + + """ + if isinstance(b, binary_type): + try: + return b.decode('utf8') + except UnicodeDecodeError: + return '0x' + binascii.hexlify(b).decode('ascii') + return b + + +def to_string(value): + """Convert *value* to a string.""" + if isinstance(value, binary_type): + return bytes_to_string(value) + else: + return text_type(value) + + +def truncate_string(value, max_width=None, skip_multiline_string=True): + """Truncate string values.""" + if skip_multiline_string and isinstance(value, text_type) and '\n' in value: + return value + elif isinstance(value, text_type) and max_width is not None and len(value) > max_width: + return value[:max_width-3] + "..." + return value + + +def intlen(n): + """Find the length of the integer part of a number *n*.""" + pos = n.find('.') + return len(n) if pos < 0 else pos + + +def filter_dict_by_key(d, keys): + """Filter the dict *d* to remove keys not in *keys*.""" + return {k: v for k, v in d.items() if k in keys} + + +def unique_items(seq): + """Return the unique items from iterable *seq* (in order).""" + seen = set() + return [x for x in seq if not (x in seen or seen.add(x))] + + +_ansi_re = re.compile('\033\\[((?:\\d|;)*)([a-zA-Z])') + + +def strip_ansi(value): + """Strip the ANSI escape sequences from a string.""" + return _ansi_re.sub('', value) + + +def replace(s, replace): + """Replace multiple values in a string""" + for r in replace: + s = s.replace(*r) + return s + + +@lru_cache() +def _get_formatter(style) -> Terminal256Formatter: + return Terminal256Formatter(style=style) + + +def style_field(token, field, style): + """Get the styled text for a *field* using *token* type.""" + formatter = _get_formatter(style) + s = StringIO() + formatter.format(((token, field),), s) + return s.getvalue() + + +def filter_style_table(style: "StyleMeta", *relevant_styles: str) -> Dict: + """ + get a dictionary of styles for given tokens. Typical usage: + + filter_style_table(style, 'Token.Output.EvenRow', 'Token.Output.OddRow') == { + 'Token.Output.EvenRow': "", + 'Token.Output.OddRow': "", + } + """ + _styles_iter = ((str(key), val) for key, val in getattr(style, 'styles', {}).items()) + _relevant_styles_iter = filter( + lambda tpl: tpl[0] in relevant_styles, + _styles_iter + ) + return {key: val for key, val in _relevant_styles_iter} diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..4efb828 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = CLIHelpers +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..a95d314 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,23 @@ +API +=== + +.. automodule:: cli_helpers + +Tabular Output +-------------- + +.. automodule:: cli_helpers.tabular_output + :members: + :imported-members: + +Preprocessors ++++++++++++++ + +.. automodule:: cli_helpers.tabular_output.preprocessors + :members: + +Config +------ + +.. automodule:: cli_helpers.config + :members: diff --git a/docs/source/authors.rst b/docs/source/authors.rst new file mode 100644 index 0000000..5078189 --- /dev/null +++ b/docs/source/authors.rst @@ -0,0 +1 @@ +.. include:: ../../AUTHORS diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..e272c5a --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1 @@ +.. include:: ../../CHANGELOG diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..4944893 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# CLI Helpers documentation build configuration file, created by +# sphinx-quickstart on Mon Apr 17 20:26:02 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import ast +from collections import OrderedDict +# import os +import re +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +html_sidebars = { + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', + 'searchbox.html', + 'donate.html', + ] +} + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'CLI Helpers' +author = 'dbcli' +description = 'Python helpers for common CLI tasks' +copyright = '2017, dbcli' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +_version_re = re.compile(r'__version__\s+=\s+(.*)') +with open('../../cli_helpers/__init__.py', 'rb') as f: + version = str(ast.literal_eval(_version_re.search( + f.read().decode('utf-8')).group(1))) + +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. + +nav_links = OrderedDict(( + ('CLI Helpers at GitHub', 'https://github.com/dbcli/cli_helpers'), + ('CLI Helpers at PyPI', 'https://pypi.org/project/cli_helpers'), + ('Issue Tracker', 'https://github.com/dbcli/cli_helpers/issues') +)) + +html_theme_options = { + 'description': description, + 'github_user': 'dbcli', + 'github_repo': 'cli_helpers', + 'github_banner': False, + 'github_button': False, + 'github_type': 'watch', + 'github_count': False, + 'extra_nav_links': nav_links +} + + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'CLIHelpersdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'CLIHelpers.tex', 'CLI Helpers Documentation', + 'dbcli', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'clihelpers', 'CLI Helpers Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'CLIHelpers', 'CLI Helpers Documentation', + author, 'CLIHelpers', description, + 'Miscellaneous'), +] + + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'py2': ('https://docs.python.org/2', None), + 'pymysql': ('https://pymysql.readthedocs.io/en/latest/', None), + 'numpy': ('https://docs.scipy.org/doc/numpy', None), + 'configobj': ('https://configobj.readthedocs.io/en/latest', None) +} diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst new file mode 100644 index 0000000..ac7b6bc --- /dev/null +++ b/docs/source/contributing.rst @@ -0,0 +1 @@ +.. include:: ../../CONTRIBUTING.rst diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..09035fe --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,30 @@ +Welcome to CLI Helpers +====================== + +.. include:: ../../README.rst + :start-after: start-body + :end-before: end-body + +Installation +------------ +You can get the library directly from `PyPI `_:: + + $ pip install cli_helpers + +User Guide +---------- +.. toctree:: + :maxdepth: 2 + + quickstart + contributing + changelog + authors + license + +API +--- +.. toctree:: + :maxdepth: 2 + + api diff --git a/docs/source/license.rst b/docs/source/license.rst new file mode 100644 index 0000000..caa73a3 --- /dev/null +++ b/docs/source/license.rst @@ -0,0 +1,13 @@ +License +======= + +CLI Helpers is licensed under the BSD 3-clause license. This basically means +you can do what you'd like with the source code as long as you include a copy +of the license, don't modify the conditions, and keep the disclaimer around. +Plus, you can't use the authors' names to promote your software without their +written consent. + +License Text +++++++++++++ + +.. include:: ../../LICENSE diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst new file mode 100644 index 0000000..0cbd45b --- /dev/null +++ b/docs/source/quickstart.rst @@ -0,0 +1,153 @@ +Quickstart +========== + +Displaying Tabular Data +----------------------- + + +The Basics +++++++++++ + +CLI Helpers provides a simple way to display your tabular data (columns/rows) in a visually-appealing manner:: + + >>> from cli_helpers import tabular_output + + >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] + >>> headers = ['id', 'city', 'visited'] + + >>> print(tabular_output.format_output(data, headers, format_name='simple')) + + id city visited + ---- --------- --------- + 1 Asgard True + 2 Camelot False + 3 El Dorado True + +Let's take a look at what we did there. + +1. We imported the :mod:`~cli_helpers.tabular_output` module. This module gives us access to the :func:`~cli_helpers.tabular_output.format_output` function. + +2. Next we generate some data. Plus, we need a list of headers to give our data some context. + +3. We format the output using the display format ``simple``. That's a nice looking table! + + +Display Formats ++++++++++++++++ + +To display your data, :mod:`~cli_helpers.tabular_output` uses +`tabulate `_, +`terminaltables `_, :mod:`csv`, +and its own vertical table layout. + +The best way to see the various display formats is to use the +:class:`~cli_helpers.tabular_output.TabularOutputFormatter` class. This is +what the :func:`~cli_helpers.tabular_output.format_output` function in our +first example uses behind the scenes. + +Let's get a list of all the supported format names:: + + >>> from cli_helpers.tabular_output import TabularOutputFormatter + >>> formatter = TabularOutputFormatter() + >>> formatter.supported_formats + ('vertical', 'csv', 'tsv', 'mediawiki', 'html', 'latex', 'latex_booktabs', 'textile', 'moinmoin', 'jira', 'plain', 'simple', 'grid', 'fancy_grid', 'pipe', 'orgtbl', 'psql', 'rst', 'ascii', 'double', 'github') + +You can format your data in any of those supported formats. Let's take the +same data from our first example and put it in the ``fancy_grid`` format:: + + >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] + >>> headers = ['id', 'city', 'visited'] + >>> print(formatter.format_output(data, headers, format_name='fancy_grid')) + ╒══════╤═══════════╤═══════════╕ + │ id │ city │ visited │ + ╞══════╪═══════════╪═══════════╡ + │ 1 │ Asgard │ True │ + ├──────┼───────────┼───────────┤ + │ 2 │ Camelot │ False │ + ├──────┼───────────┼───────────┤ + │ 3 │ El Dorado │ True │ + ╘══════╧═══════════╧═══════════╛ + +That was easy! How about CLI Helper's vertical table layout? + + >>> print(formatter.format_output(data, headers, format_name='vertical')) + ***************************[ 1. row ]*************************** + id | 1 + city | Asgard + visited | True + ***************************[ 2. row ]*************************** + id | 2 + city | Camelot + visited | False + ***************************[ 3. row ]*************************** + id | 3 + city | El Dorado + visited | True + + +Default Format +++++++++++++++ + +When you create a :class:`~cli_helpers.tabular_output.TabularOutputFormatter` +object, you can specify a default formatter so you don't have to pass the +format name each time you want to format your data:: + + >>> formatter = TabularOutputFormatter(format_name='plain') + >>> print(formatter.format_output(data, headers)) + id city visited + 1 Asgard True + 2 Camelot False + 3 El Dorado True + +.. TIP:: + You can get or set the default format whenever you'd like through + :data:`TabularOutputFormatter.format_name `. + + +Passing Options to the Formatters ++++++++++++++++++++++++++++++++++ + +Many of the formatters have settings that can be tweaked by passing +an optional argument when you format your data. For example, +if we wanted to enable or disable number parsing on any of +`tabulate's `_ +formats, we could:: + + >>> data = [[1, 1.5], [2, 19.605], [3, 100.0]] + >>> headers = ['id', 'rating'] + >>> print(format_output(data, headers, format_name='simple', disable_numparse=True)) + id rating + ---- -------- + 1 1.5 + 2 19.605 + 3 100.0 + >>> print(format_output(data, headers, format_name='simple', disable_numparse=False)) + id rating + ---- -------- + 1 1.5 + 2 19.605 + 3 100 + + +Lists and tuples and bytearrays. Oh my! ++++++++++++++++++++++++++++++++++++++++ + +:mod:`~cli_helpers.tabular_output` supports any :term:`iterable`, not just +a :class:`list` or :class:`tuple`. You can use a :class:`range`, +:func:`enumerate`, a :class:`str`, or even a :class:`bytearray`! Here is a +far-fetched example to prove the point:: + + >>> step = 3 + >>> data = [range(n, n + step) for n in range(0, 9, step)] + >>> headers = 'abc' + >>> print(format_output(data, headers, format_name='simple')) + a b c + --- --- --- + 0 1 2 + 3 4 5 + 6 7 8 + +Real life examples include a PyMySQL +:class:`Cursor ` with +database results or +NumPy :class:`ndarray ` with data points. diff --git a/release.py b/release.py new file mode 100644 index 0000000..7a68271 --- /dev/null +++ b/release.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +"""A script to publish a release of cli_helpers to PyPI.""" + +import io +from optparse import OptionParser +import re +import subprocess +import sys + +import click + +DEBUG = False +CONFIRM_STEPS = False +DRY_RUN = False + + +def skip_step(): + """ + Asks for user's response whether to run a step. Default is yes. + :return: boolean + """ + global CONFIRM_STEPS + + if CONFIRM_STEPS: + return not click.confirm("--- Run this step?", default=True) + return False + + +def run_step(*args): + """ + Prints out the command and asks if it should be run. + If yes (default), runs it. + :param args: list of strings (command and args) + """ + global DRY_RUN + + cmd = args + print(" ".join(cmd)) + if skip_step(): + print("--- Skipping...") + elif DRY_RUN: + print("--- Pretending to run...") + else: + subprocess.check_output(cmd) + + +def version(version_file): + _version_re = re.compile( + r'__version__\s+=\s+(?P[\'"])(?P.*)(?P=quote)' + ) + + with io.open(version_file, encoding="utf-8") as f: + ver = _version_re.search(f.read()).group("version") + + return ver + + +def commit_for_release(version_file, ver): + run_step("git", "reset") + run_step("git", "add", version_file) + run_step("git", "commit", "--message", "Releasing version {}".format(ver)) + + +def create_git_tag(tag_name): + run_step("git", "tag", tag_name) + + +def create_distribution_files(): + run_step("python", "setup.py", "clean", "--all", "sdist", "bdist_wheel") + + +def upload_distribution_files(): + run_step("twine", "upload", "dist/*") + + +def push_to_github(): + run_step("git", "push", "origin", "master") + + +def push_tags_to_github(): + run_step("git", "push", "--tags", "origin") + + +def checklist(questions): + for question in questions: + if not click.confirm("--- {}".format(question), default=False): + sys.exit(1) + + +if __name__ == "__main__": + if DEBUG: + subprocess.check_output = lambda x: x + + checks = [ + "Have you updated the AUTHORS file?", + "Have you updated the `Usage` section of the README?", + ] + checklist(checks) + + ver = version("cli_helpers/__init__.py") + print("Releasing Version:", ver) + + parser = OptionParser() + parser.add_option( + "-c", + "--confirm-steps", + action="store_true", + dest="confirm_steps", + default=False, + help=( + "Confirm every step. If the step is not " "confirmed, it will be skipped." + ), + ) + parser.add_option( + "-d", + "--dry-run", + action="store_true", + dest="dry_run", + default=False, + help="Print out, but not actually run any steps.", + ) + + popts, pargs = parser.parse_args() + CONFIRM_STEPS = popts.confirm_steps + DRY_RUN = popts.dry_run + + if not click.confirm("Are you sure?", default=False): + sys.exit(1) + + commit_for_release("cli_helpers/__init__.py", ver) + create_git_tag("v{}".format(ver)) + create_distribution_files() + push_to_github() + push_tags_to_github() + upload_distribution_files() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c2f38d1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +autopep8==1.3.3 +codecov==2.0.9 +coverage==4.3.4 +pep8radius +Pygments>=2.4.0 +pytest==3.0.7 +pytest-cov==2.4.0 +Sphinx==1.5.5 +tox==2.7.0 +twine==1.12.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9b4f8cd --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[coverage:run] +source = cli_helpers +omit = cli_helpers/packages/*.py + +[check-manifest] +ignore = + appveyor.yml + .travis.yml + .github* + .travis* + +[tool:pytest] +testpaths = tests diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..6cf886f --- /dev/null +++ b/setup.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import ast +from io import open +import re +import sys + +from setuptools import find_packages, setup + +_version_re = re.compile(r'__version__\s+=\s+(.*)') + +with open('cli_helpers/__init__.py', 'rb') as f: + version = str(ast.literal_eval(_version_re.search( + f.read().decode('utf-8')).group(1))) + + +def open_file(filename): + """Open and read the file *filename*.""" + with open(filename) as f: + return f.read() + + +readme = open_file('README.rst') + +if sys.version_info[0] == 2: + py2_reqs = ['backports.csv >= 1.0.0'] +else: + py2_reqs = [] + +setup( + name='cli_helpers', + author='dbcli', + author_email='thomas@roten.us', + version=version, + url='https://github.com/dbcli/cli_helpers', + packages=find_packages(exclude=['docs', 'tests', 'tests.tabular_output']), + include_package_data=True, + description='Helpers for building command-line apps', + long_description=readme, + long_description_content_type='text/x-rst', + install_requires=[ + 'configobj >= 5.0.5', + 'tabulate[widechars] >= 0.8.2', + 'terminaltables >= 3.0.0', + ] + py2_reqs, + extras_require={ + 'styles': ['Pygments >= 1.6'], + }, + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: Unix', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Software Development', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Terminals :: Terminal Emulators/X Terminals', + ] +) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..aaed0f3 --- /dev/null +++ b/tasks.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""Common development tasks for setup.py to use.""" + +import re +import subprocess +import sys + +from setuptools import Command + + +class BaseCommand(Command, object): + """The base command for project tasks.""" + + user_options = [] + + default_cmd_options = ('verbose', 'quiet', 'dry_run') + + def __init__(self, *args, **kwargs): + super(BaseCommand, self).__init__(*args, **kwargs) + self.verbose = False + + def initialize_options(self): + """Override the distutils abstract method.""" + pass + + def finalize_options(self): + """Override the distutils abstract method.""" + # Distutils uses incrementing integers for verbosity. + self.verbose = bool(self.verbose) + + def call_and_exit(self, cmd, shell=True): + """Run the *cmd* and exit with the proper exit code.""" + sys.exit(subprocess.call(cmd, shell=shell)) + + def call_in_sequence(self, cmds, shell=True): + """Run multiple commmands in a row, exiting if one fails.""" + for cmd in cmds: + if subprocess.call(cmd, shell=shell) == 1: + sys.exit(1) + + def apply_options(self, cmd, options=()): + """Apply command-line options.""" + for option in (self.default_cmd_options + options): + cmd = self.apply_option(cmd, option, + active=getattr(self, option, False)) + return cmd + + def apply_option(self, cmd, option, active=True): + """Apply a command-line option.""" + return re.sub(r'{{{}\:(?P