├── .git-blame-ignore-revs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── codeql.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── .travis └── install.sh ├── AUTHORS ├── CHANGELOG ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── appveyor.yml ├── cli_helpers ├── __init__.py ├── compat.py ├── config.py ├── tabular_output │ ├── __init__.py │ ├── delimited_output_adapter.py │ ├── output_formatter.py │ ├── preprocessors.py │ ├── tabulate_adapter.py │ ├── tsv_output_adapter.py │ └── vertical_table_adapter.py └── utils.py ├── docs ├── Makefile └── source │ ├── api.rst │ ├── authors.rst │ ├── changelog.rst │ ├── conf.py │ ├── contributing.rst │ ├── index.rst │ ├── license.rst │ └── quickstart.rst ├── release.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tasks.py ├── tests ├── __init__.py ├── compat.py ├── config_data │ ├── configrc │ ├── configspecrc │ ├── invalid_configrc │ └── invalid_configspecrc ├── tabular_output │ ├── __init__.py │ ├── test_delimited_output_adapter.py │ ├── test_output_formatter.py │ ├── test_preprocessors.py │ ├── test_tabulate_adapter.py │ ├── test_tsv_output_adapter.py │ └── test_vertical_table_adapter.py ├── test_cli_helpers.py ├── test_config.py ├── test_utils.py └── utils.py └── tox.ini /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Black all the code. 2 | 33e8b461b6ddb717859dde664b71209ce69c119a 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | ## Checklist 7 | 8 | - [ ] I've added this contribution to the `CHANGELOG`. 9 | - [ ] I've added my name to the `AUTHORS` file (or it's already there). 10 | - [ ] I installed pre-commit hooks (`pip install pre-commit && pre-commit install`), and ran `black` on my code. 11 | - [x] Please squash merge this pull request (uncheck if you'd like us to merge as multiple commits) 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "36 4 * * 1" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.egg 4 | *.egg-info 5 | .coverage 6 | /.tox 7 | /build 8 | /docs/build 9 | /dist 10 | /cli_helpers.egg-info 11 | /cli_helpers_dev 12 | .idea/ 13 | .cache/ 14 | .vscode/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | language_version: python3.7 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | cache: pip 4 | install: ./.travis/install.sh 5 | script: 6 | - source ~/.venv/bin/activate 7 | - tox 8 | - if [[ "$TOXENV" == "py37" ]]; then black --check cli_helpers tests ; else echo "Skipping black for $TOXENV"; fi 9 | matrix: 10 | include: 11 | - os: linux 12 | python: 3.6 13 | env: TOXENV=py36 14 | - os: linux 15 | python: 3.6 16 | env: TOXENV=noextras 17 | - os: linux 18 | python: 3.6 19 | env: TOXENV=docs 20 | - os: linux 21 | python: 3.6 22 | env: TOXENV=packaging 23 | - os: osx 24 | language: generic 25 | env: TOXENV=py36 26 | - os: linux 27 | python: 3.7 28 | env: TOXENV=py37 29 | dist: xenial 30 | sudo: true 31 | -------------------------------------------------------------------------------- /.travis/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | if [[ "$(uname -s)" == 'Darwin' ]]; then 6 | sw_vers 7 | 8 | git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv 9 | export PYENV_ROOT="$HOME/.pyenv" 10 | export PATH="$PYENV_ROOT/bin:$PATH" 11 | eval "$(pyenv init --path)" 12 | 13 | case "${TOXENV}" in 14 | py36) 15 | pyenv install 3.6.1 16 | pyenv global 3.6.1 17 | ;; 18 | esac 19 | pyenv rehash 20 | fi 21 | 22 | pip install virtualenv 23 | python -m virtualenv ~/.venv 24 | source ~/.venv/bin/activate 25 | pip install -r requirements-dev.txt -U --upgrade-strategy only-if-needed 26 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | CLI Helpers is written and maintained by the following people: 5 | 6 | - Amjith Ramanujam 7 | - Dick Marinus 8 | - Irina Truong 9 | - Thomas Roten 10 | 11 | 12 | Contributors 13 | ------------ 14 | 15 | This project receives help from these awesome contributors: 16 | 17 | - Terje Røsten 18 | - Frederic Aoustin 19 | - Zhaolong Zhu 20 | - Karthikeyan Singaravelan 21 | - laixintao 22 | - Georgy Frolov 23 | - Michał Górny 24 | - Waldir Pimenta 25 | - Mel Dafert 26 | - Andrii Kohut 27 | - Roland Walker 28 | - Doug Harris 29 | 30 | Thanks 31 | ------ 32 | 33 | This project exists because of the amazing contributors from 34 | `pgcli `_ and `mycli `_. 35 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 2.4.0 4 | 5 | (released on 2025-03-10) 6 | 7 | - Added format_timestamps preprocessor for per-column date/time formatting. 8 | 9 | ## Version 2.3.1 10 | 11 | - Don't escape newlines in `ascii` tables, and add `ascii_escaped` table format. 12 | - Updated tabulate version to latest, to fix ImportError in pgcli. 13 | 14 | ## Version 2.2.1 15 | 16 | (released on 2022-01-17) 17 | 18 | - Fix pygments tokens passed as strings 19 | 20 | ## Version 2.2.0 21 | 22 | (released on 2021-08-27) 23 | 24 | - Remove dependency on terminaltables 25 | - Add psql_unicode table format 26 | - Add minimal table format 27 | - Fix pip2 installing py3-only versions 28 | - Format unprintable bytes (eg 0x00, 0x01) as hex 29 | 30 | ## Version 2.1.0 31 | 32 | (released on 2020-07-29) 33 | 34 | - Speed up output styling of tables. 35 | 36 | ## Version 2.0.1 37 | 38 | (released on 2020-05-27) 39 | 40 | - Fix newline escaping in plain-text formatters (ascii, double, github) 41 | - Use built-in unittest.mock instead of mock. 42 | 43 | ## Version 2.0.0 44 | 45 | (released on 2020-05-26) 46 | 47 | - Remove Python 2.7 and 3.5. 48 | - Style config for missing value. 49 | 50 | ## Version 1.2.1 51 | 52 | (released on 2019-06-09) 53 | 54 | - Pin Pygments to >= 2.4.0 for tests. 55 | - Remove Python 3.4 from tests and Trove classifier. 56 | - Add an option to skip truncating multi-line strings. 57 | - When truncating long strings, add ellipsis. 58 | 59 | ## Version 1.2.0 60 | 61 | (released on 2019-04-05) 62 | 63 | - Fix issue with writing non-ASCII characters to config files. 64 | - Run tests on Python 3.7. 65 | - Use twine check during packaging tests. 66 | - Rename old tsv format to csv-tab (because it add quotes), introduce new tsv output adapter. 67 | - Truncate long fields for tabular display. 68 | - Return the supported table formats as unicode. 69 | - Override tab with 4 spaces for terminal tables. 70 | 71 | ## Version 1.1.0 72 | 73 | (released on 2018-10-18) 74 | 75 | - Adds config file reading/writing. 76 | - Style formatted tables with Pygments (optional). 77 | 78 | ## Version 1.0.2 79 | 80 | (released on 2018-04-07) 81 | 82 | - Copy unit test from pgcli 83 | - Use safe float for unit test 84 | - Move strip_ansi from tests.utils to cli_helpers.utils 85 | 86 | ## Version 1.0.1 87 | 88 | (released on 2017-11-27) 89 | 90 | - Output all unicode for terminaltables, add unit test. 91 | 92 | ## Version 1.0.0 93 | 94 | (released on 2017-10-11) 95 | 96 | - Output as generator 97 | - Use backports.csv only for py2 98 | - Require tabulate as a dependency instead of using vendored module. 99 | - Drop support for Python 3.3. 100 | 101 | ## Version 0.2.3 102 | 103 | (released on 2017-08-01) 104 | 105 | - Fix unicode error on Python 2 with newlines in output row. 106 | - Fixes to accept iterator. 107 | 108 | ## Version 0.2.2 109 | 110 | (released on 2017-07-16) 111 | 112 | - Fix IndexError from being raised with uneven rows. 113 | 114 | ## Version 0.2.1 115 | 116 | (released on 2017-07-11) 117 | 118 | - Run tests on macOS via Travis. 119 | - Fix unicode issues on Python 2 (csv and styling output). 120 | 121 | ## Version 0.2.0 122 | 123 | (released on 2017-06-23) 124 | 125 | - Make vertical table separator more customizable. 126 | - Add format numbers preprocessor. 127 | - Add test coverage reports. 128 | - Add ability to pass additional preprocessors when formatting output. 129 | - Don't install tests.tabular_output. 130 | - Add .gitignore 131 | - Coverage for tox tests. 132 | - Style formatted output with Pygments (optional). 133 | - Fix issue where tabulate can't handle ANSI escape codes in default values. 134 | - Run tests on Windows via Appveyor. 135 | 136 | ## Version 0.1.0 137 | 138 | (released on 2017-05-01) 139 | 140 | - Pretty print tabular data using a variety of formatting libraries. 141 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How to Contribute 2 | ================= 3 | 4 | CLI Helpers would love your help! We appreciate your time and always give credit. 5 | 6 | Development Setup 7 | ----------------- 8 | 9 | Ready to contribute? Here's how to set up CLI Helpers for local development. 10 | 11 | 1. `Fork the repository `_ on GitHub. 12 | 2. Clone your fork locally:: 13 | 14 | $ git clone 15 | 16 | 3. Add the official repository (``upstream``) as a remote repository:: 17 | 18 | $ git remote add upstream git@github.com:dbcli/cli_helpers.git 19 | 20 | 4. Set up a `virtual environment `_ 21 | for development:: 22 | 23 | $ cd cli_helpers 24 | $ pip install virtualenv 25 | $ virtualenv cli_helpers_dev 26 | 27 | We've just created a virtual environment called ``cli_helpers_dev`` 28 | that we'll use to install all the dependencies and tools we need to work on CLI Helpers. 29 | Whenever you want to work on CLI Helpers, you need to activate the virtual environment:: 30 | 31 | $ source cli_helpers_dev/bin/activate 32 | 33 | When you're done working, you can deactivate the virtual environment:: 34 | 35 | $ deactivate 36 | 37 | 5. From within the virtual environment, install the dependencies and development tools:: 38 | 39 | $ pip install -r requirements-dev.txt 40 | $ pip install --editable . 41 | 42 | 6. Create a branch for your bugfix or feature based off the ``master`` branch:: 43 | 44 | $ git checkout -b master 45 | 46 | 7. While you work on your bugfix or feature, be sure to pull the latest changes from ``upstream``. 47 | This ensures that your local codebase is up-to-date:: 48 | 49 | $ git pull upstream master 50 | 51 | 8. When your work is ready for the CLI Helpers team to review it, 52 | make sure to add an entry to CHANGELOG file, and add your name to the AUTHORS file. 53 | Then, push your branch to your fork:: 54 | 55 | $ git push origin 56 | 57 | 9. `Create a pull request `_ 58 | on GitHub. 59 | 60 | 61 | Running the Tests 62 | ----------------- 63 | 64 | While you work on CLI Helpers, it's important to run the tests to make sure your code 65 | hasn't broken any existing functionality. To run the tests, just type in:: 66 | 67 | $ pytest 68 | 69 | CLI Helpers supports Python 3.6+. You can test against multiple versions of 70 | Python by running:: 71 | 72 | $ tox 73 | 74 | You can also measure CLI Helper's test coverage by running:: 75 | 76 | $ pytest --cov-report= --cov=cli_helpers 77 | $ coverage report 78 | 79 | 80 | Coding Style 81 | ------------ 82 | 83 | When you submit a PR, the changeset is checked for pep8 compliance using 84 | `black `_. If you see a build failing because 85 | of these checks, install ``black`` and apply style fixes: 86 | 87 | :: 88 | 89 | $ pip install black 90 | $ black . 91 | 92 | Then commit and push the fixes. 93 | 94 | To enforce ``black`` applied on every commit, we also suggest installing ``pre-commit`` and 95 | using the ``pre-commit`` hooks available in this repo: 96 | 97 | :: 98 | 99 | $ pip install pre-commit 100 | $ pre-commit install 101 | 102 | Git blame 103 | --------- 104 | 105 | Use ``git blame my_file.py --ignore-revs-file .git-blame-ignore-revs`` to exclude irrelevant commits 106 | (specifically Black) from ``git blame``. For more information, 107 | see `here `_. 108 | 109 | Documentation 110 | ------------- 111 | 112 | If your work in CLI Helpers requires a documentation change or addition, you can 113 | build the documentation by running:: 114 | 115 | $ make -C docs clean html 116 | $ open docs/build/html/index.html 117 | 118 | That will build the documentation and open it in your web browser. 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, dbcli 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of dbcli nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.rst *.py 2 | include AUTHORS CHANGELOG LICENSE 3 | include tox.ini 4 | recursive-include docs *.py 5 | recursive-include docs *.rst 6 | recursive-include docs Makefile 7 | recursive-include tests *.py 8 | include tests/config_data/* 9 | exclude .pre-commit-config.yaml .git-blame-ignore-revs -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | CLI Helpers 3 | =========== 4 | 5 | .. image:: https://travis-ci.org/dbcli/cli_helpers.svg?branch=master 6 | :target: https://travis-ci.org/dbcli/cli_helpers 7 | 8 | .. image:: https://ci.appveyor.com/api/projects/status/37a1ri2nbcp237tr/branch/master?svg=true 9 | :target: https://ci.appveyor.com/project/dbcli/cli-helpers 10 | 11 | .. image:: https://codecov.io/gh/dbcli/cli_helpers/branch/master/graph/badge.svg 12 | :target: https://codecov.io/gh/dbcli/cli_helpers 13 | 14 | .. image:: https://img.shields.io/pypi/v/cli_helpers.svg?style=flat 15 | :target: https://pypi.python.org/pypi/cli_helpers 16 | 17 | .. start-body 18 | 19 | CLI Helpers is a Python package that makes it easy to perform common tasks when 20 | building command-line apps. It's a helper library for command-line interfaces. 21 | 22 | Libraries like `Click `_ and 23 | `Python Prompt Toolkit `_ 24 | are amazing tools that help you create quality apps. CLI Helpers complements 25 | these libraries by wrapping up common tasks in simple interfaces. 26 | 27 | CLI Helpers is not focused on your app's design pattern or framework -- you can 28 | use it on its own or in combination with other libraries. It's lightweight and 29 | easy to extend. 30 | 31 | What's included in CLI Helpers? 32 | 33 | - Prettyprinting of tabular data with custom pre-processing 34 | - Config file reading/writing 35 | 36 | .. end-body 37 | 38 | Read the documentation at http://cli-helpers.rtfd.io 39 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - PYTHON: "C:\\Python36" 4 | - PYTHON: "C:\\Python37" 5 | 6 | build: off 7 | 8 | before_test: 9 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" 10 | - pip install -r requirements-dev.txt 11 | - pip install -e . 12 | test_script: 13 | - pytest --cov-report= --cov=cli_helpers 14 | - coverage report 15 | - codecov 16 | -------------------------------------------------------------------------------- /cli_helpers/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.4.0" 2 | -------------------------------------------------------------------------------- /cli_helpers/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """OS and Python compatibility support.""" 3 | 4 | from decimal import Decimal 5 | from types import SimpleNamespace 6 | import sys 7 | 8 | PY2 = sys.version_info[0] == 2 9 | WIN = sys.platform.startswith("win") 10 | MAC = sys.platform == "darwin" 11 | 12 | 13 | if PY2: 14 | text_type = unicode 15 | binary_type = str 16 | long_type = long 17 | int_types = (int, long) 18 | 19 | from UserDict import UserDict 20 | from backports import csv 21 | 22 | from StringIO import StringIO 23 | from itertools import izip_longest as zip_longest 24 | else: 25 | text_type = str 26 | binary_type = bytes 27 | long_type = int 28 | int_types = (int,) 29 | 30 | from collections import UserDict 31 | import csv 32 | from io import StringIO 33 | from itertools import zip_longest 34 | 35 | 36 | HAS_PYGMENTS = True 37 | try: 38 | from pygments.token import Token 39 | from pygments.formatters.terminal256 import Terminal256Formatter 40 | except ImportError: 41 | HAS_PYGMENTS = False 42 | Terminal256Formatter = None 43 | Token = SimpleNamespace() 44 | Token.Output = SimpleNamespace() 45 | Token.Output.Header = None 46 | Token.Output.OddRow = None 47 | Token.Output.EvenRow = None 48 | Token.Output.Null = None 49 | Token.Output.TableSeparator = None 50 | Token.Results = SimpleNamespace() 51 | Token.Results.Header = None 52 | Token.Results.OddRow = None 53 | Token.Results.EvenRow = None 54 | 55 | 56 | float_types = (float, Decimal) 57 | -------------------------------------------------------------------------------- /cli_helpers/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Read and write an application's config files.""" 3 | 4 | from __future__ import unicode_literals 5 | import io 6 | import logging 7 | import os 8 | 9 | from configobj import ConfigObj, ConfigObjError 10 | from validate import ValidateError, Validator 11 | 12 | from .compat import MAC, text_type, UserDict, WIN 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ConfigError(Exception): 18 | """Base class for exceptions in this module.""" 19 | 20 | pass 21 | 22 | 23 | class DefaultConfigValidationError(ConfigError): 24 | """Indicates the default config file did not validate correctly.""" 25 | 26 | pass 27 | 28 | 29 | class Config(UserDict, object): 30 | """Config reader/writer class. 31 | 32 | :param str app_name: The application's name. 33 | :param str app_author: The application author/organization. 34 | :param str filename: The config filename to look for (e.g. ``config``). 35 | :param dict/str default: The default config values or absolute path to 36 | config file. 37 | :param bool validate: Whether or not to validate the config file. 38 | :param bool write_default: Whether or not to write the default config 39 | file to the user config directory if it doesn't 40 | already exist. 41 | :param tuple additional_dirs: Additional directories to check for a config 42 | file. 43 | """ 44 | 45 | def __init__( 46 | self, 47 | app_name, 48 | app_author, 49 | filename, 50 | default=None, 51 | validate=False, 52 | write_default=False, 53 | additional_dirs=(), 54 | ): 55 | super(Config, self).__init__() 56 | #: The :class:`ConfigObj` instance. 57 | self.data = ConfigObj(encoding="utf8") 58 | 59 | self.default = {} 60 | self.default_file = self.default_config = None 61 | self.config_filenames = [] 62 | 63 | self.app_name, self.app_author = app_name, app_author 64 | self.filename = filename 65 | self.write_default = write_default 66 | self.validate = validate 67 | self.additional_dirs = additional_dirs 68 | 69 | if isinstance(default, dict): 70 | self.default = default 71 | self.update(default) 72 | elif isinstance(default, text_type): 73 | self.default_file = default 74 | elif default is not None: 75 | raise TypeError( 76 | '"default" must be a dict or {}, not {}'.format( 77 | text_type.__name__, type(default) 78 | ) 79 | ) 80 | 81 | if self.write_default and not self.default_file: 82 | raise ValueError( 83 | 'Cannot use "write_default" without specifying ' "a default file." 84 | ) 85 | 86 | if self.validate and not self.default_file: 87 | raise ValueError( 88 | 'Cannot use "validate" without specifying a ' "default file." 89 | ) 90 | 91 | def read_default_config(self): 92 | """Read the default config file. 93 | 94 | :raises DefaultConfigValidationError: There was a validation error with 95 | the *default* file. 96 | """ 97 | if self.validate: 98 | self.default_config = ConfigObj( 99 | configspec=self.default_file, 100 | list_values=False, 101 | _inspec=True, 102 | encoding="utf8", 103 | ) 104 | # ConfigObj does not set the encoding on the configspec. 105 | self.default_config.configspec.encoding = "utf8" 106 | 107 | valid = self.default_config.validate( 108 | Validator(), copy=True, preserve_errors=True 109 | ) 110 | if valid is not True: 111 | for name, section in valid.items(): 112 | if section is True: 113 | continue 114 | for key, value in section.items(): 115 | if isinstance(value, ValidateError): 116 | raise DefaultConfigValidationError( 117 | 'section [{}], key "{}": {}'.format(name, key, value) 118 | ) 119 | elif self.default_file: 120 | self.default_config, _ = self.read_config_file(self.default_file) 121 | 122 | self.update(self.default_config) 123 | 124 | def read(self): 125 | """Read the default, additional, system, and user config files. 126 | 127 | :raises DefaultConfigValidationError: There was a validation error with 128 | the *default* file. 129 | """ 130 | if self.default_file: 131 | self.read_default_config() 132 | return self.read_config_files(self.all_config_files()) 133 | 134 | def user_config_file(self): 135 | """Get the absolute path to the user config file.""" 136 | return os.path.join( 137 | get_user_config_dir(self.app_name, self.app_author), self.filename 138 | ) 139 | 140 | def system_config_files(self): 141 | """Get a list of absolute paths to the system config files.""" 142 | return [ 143 | os.path.join(f, self.filename) 144 | for f in get_system_config_dirs(self.app_name, self.app_author) 145 | ] 146 | 147 | def additional_files(self): 148 | """Get a list of absolute paths to the additional config files.""" 149 | return [os.path.join(f, self.filename) for f in self.additional_dirs] 150 | 151 | def all_config_files(self): 152 | """Get a list of absolute paths to all the config files.""" 153 | return ( 154 | self.additional_files() 155 | + self.system_config_files() 156 | + [self.user_config_file()] 157 | ) 158 | 159 | def write_default_config(self, overwrite=False): 160 | """Write the default config to the user's config file. 161 | 162 | :param bool overwrite: Write over an existing config if it exists. 163 | """ 164 | destination = self.user_config_file() 165 | if not overwrite and os.path.exists(destination): 166 | return 167 | 168 | with io.open(destination, mode="wb") as f: 169 | self.default_config.write(f) 170 | 171 | def write(self, outfile=None, section=None): 172 | """Write the current config to a file (defaults to user config). 173 | 174 | :param str outfile: The path to the file to write to. 175 | :param None/str section: The config section to write, or :data:`None` 176 | to write the entire config. 177 | """ 178 | with io.open(outfile or self.user_config_file(), "wb") as f: 179 | self.data.write(outfile=f, section=section) 180 | 181 | def read_config_file(self, f): 182 | """Read a config file *f*. 183 | 184 | :param str f: The path to a file to read. 185 | """ 186 | configspec = self.default_file if self.validate else None 187 | try: 188 | config = ConfigObj( 189 | infile=f, configspec=configspec, interpolation=False, encoding="utf8" 190 | ) 191 | # ConfigObj does not set the encoding on the configspec. 192 | if config.configspec is not None: 193 | config.configspec.encoding = "utf8" 194 | except ConfigObjError as e: 195 | logger.warning( 196 | "Unable to parse line {} of config file {}".format(e.line_number, f) 197 | ) 198 | config = e.config 199 | 200 | valid = True 201 | if self.validate: 202 | valid = config.validate(Validator(), preserve_errors=True, copy=True) 203 | if bool(config): 204 | self.config_filenames.append(config.filename) 205 | 206 | return config, valid 207 | 208 | def read_config_files(self, files): 209 | """Read a list of config files. 210 | 211 | :param iterable files: An iterable (e.g. list) of files to read. 212 | """ 213 | errors = {} 214 | for _file in files: 215 | config, valid = self.read_config_file(_file) 216 | self.update(config) 217 | if valid is not True: 218 | errors[_file] = valid 219 | return errors or True 220 | 221 | 222 | def get_user_config_dir(app_name, app_author, roaming=True, force_xdg=True): 223 | """Returns the config folder for the application. The default behavior 224 | is to return whatever is most appropriate for the operating system. 225 | 226 | For an example application called ``"My App"`` by ``"Acme"``, 227 | something like the following folders could be returned: 228 | 229 | macOS (non-XDG): 230 | ``~/Library/Application Support/My App`` 231 | Mac OS X (XDG): 232 | ``~/.config/my-app`` 233 | Unix: 234 | ``~/.config/my-app`` 235 | Windows 7 (roaming): 236 | ``C:\\Users\\\\AppData\\Roaming\\Acme\\My App`` 237 | Windows 7 (not roaming): 238 | ``C:\\Users\\\\AppData\\Local\\Acme\\My App`` 239 | 240 | :param app_name: the application name. This should be properly capitalized 241 | and can contain whitespace. 242 | :param app_author: The app author's name (or company). This should be 243 | properly capitalized and can contain whitespace. 244 | :param roaming: controls if the folder should be roaming or not on Windows. 245 | Has no effect on non-Windows systems. 246 | :param force_xdg: if this is set to `True`, then on macOS the XDG Base 247 | Directory Specification will be followed. Has no effect 248 | on non-macOS systems. 249 | 250 | """ 251 | if WIN: 252 | key = "APPDATA" if roaming else "LOCALAPPDATA" 253 | folder = os.path.expanduser(os.environ.get(key, "~")) 254 | return os.path.join(folder, app_author, app_name) 255 | if MAC and not force_xdg: 256 | return os.path.join( 257 | os.path.expanduser("~/Library/Application Support"), app_name 258 | ) 259 | return os.path.join( 260 | os.path.expanduser(os.environ.get("XDG_CONFIG_HOME", "~/.config")), 261 | _pathify(app_name), 262 | ) 263 | 264 | 265 | def get_system_config_dirs(app_name, app_author, force_xdg=True): 266 | r"""Returns a list of system-wide config folders for the application. 267 | 268 | For an example application called ``"My App"`` by ``"Acme"``, 269 | something like the following folders could be returned: 270 | 271 | macOS (non-XDG): 272 | ``['/Library/Application Support/My App']`` 273 | Mac OS X (XDG): 274 | ``['/etc/xdg/my-app']`` 275 | Unix: 276 | ``['/etc/xdg/my-app']`` 277 | Windows 7: 278 | ``['C:\ProgramData\Acme\My App']`` 279 | 280 | :param app_name: the application name. This should be properly capitalized 281 | and can contain whitespace. 282 | :param app_author: The app author's name (or company). This should be 283 | properly capitalized and can contain whitespace. 284 | :param force_xdg: if this is set to `True`, then on macOS the XDG Base 285 | Directory Specification will be followed. Has no effect 286 | on non-macOS systems. 287 | 288 | """ 289 | if WIN: 290 | folder = os.environ.get("PROGRAMDATA") 291 | return [os.path.join(folder, app_author, app_name)] 292 | if MAC and not force_xdg: 293 | return [os.path.join("/Library/Application Support", app_name)] 294 | dirs = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg") 295 | paths = [os.path.expanduser(x) for x in dirs.split(os.pathsep)] 296 | return [os.path.join(d, _pathify(app_name)) for d in paths] 297 | 298 | 299 | def _pathify(s): 300 | """Convert spaces to hyphens and lowercase a string.""" 301 | return "-".join(s.split()).lower() 302 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """CLI Helper's tabular output module makes it easy to format your data using 3 | various formatting libraries. 4 | 5 | When formatting data, you'll primarily use the 6 | :func:`~cli_helpers.tabular_output.format_output` function and 7 | :class:`~cli_helpers.tabular_output.TabularOutputFormatter` class. 8 | 9 | """ 10 | 11 | from .output_formatter import format_output, TabularOutputFormatter 12 | 13 | __all__ = ["format_output", "TabularOutputFormatter"] 14 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/delimited_output_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A delimited data output adapter (e.g. CSV, TSV).""" 3 | 4 | from __future__ import unicode_literals 5 | import contextlib 6 | 7 | from cli_helpers.compat import csv, StringIO 8 | from cli_helpers.utils import filter_dict_by_key 9 | from .preprocessors import bytes_to_string, override_missing_value 10 | 11 | supported_formats = ("csv", "csv-tab") 12 | preprocessors = (override_missing_value, bytes_to_string) 13 | 14 | 15 | class linewriter(object): 16 | def __init__(self): 17 | self.reset() 18 | 19 | def reset(self): 20 | self.line = None 21 | 22 | def write(self, d): 23 | self.line = d 24 | 25 | 26 | def adapter(data, headers, table_format="csv", **kwargs): 27 | """Wrap the formatting inside a function for TabularOutputFormatter.""" 28 | keys = ( 29 | "dialect", 30 | "delimiter", 31 | "doublequote", 32 | "escapechar", 33 | "quotechar", 34 | "quoting", 35 | "skipinitialspace", 36 | "strict", 37 | ) 38 | if table_format == "csv": 39 | delimiter = "," 40 | elif table_format == "csv-tab": 41 | delimiter = "\t" 42 | else: 43 | raise ValueError("Invalid table_format specified.") 44 | 45 | ckwargs = {"delimiter": delimiter, "lineterminator": ""} 46 | ckwargs.update(filter_dict_by_key(kwargs, keys)) 47 | 48 | l = linewriter() 49 | writer = csv.writer(l, **ckwargs) 50 | writer.writerow(headers) 51 | yield l.line 52 | 53 | for row in data: 54 | l.reset() 55 | writer.writerow(row) 56 | yield l.line 57 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/output_formatter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A generic tabular data output formatter interface.""" 3 | 4 | from __future__ import unicode_literals 5 | from collections import namedtuple 6 | 7 | from cli_helpers.compat import ( 8 | text_type, 9 | binary_type, 10 | int_types, 11 | float_types, 12 | zip_longest, 13 | ) 14 | from cli_helpers.utils import unique_items 15 | from . import ( 16 | delimited_output_adapter, 17 | vertical_table_adapter, 18 | tabulate_adapter, 19 | tsv_output_adapter, 20 | ) 21 | from decimal import Decimal 22 | 23 | import itertools 24 | 25 | MISSING_VALUE = "" 26 | MAX_FIELD_WIDTH = 500 27 | 28 | TYPES = { 29 | type(None): 0, 30 | bool: 1, 31 | int: 2, 32 | float: 3, 33 | Decimal: 3, 34 | binary_type: 4, 35 | text_type: 5, 36 | } 37 | 38 | OutputFormatHandler = namedtuple( 39 | "OutputFormatHandler", "format_name preprocessors formatter formatter_args" 40 | ) 41 | 42 | 43 | class TabularOutputFormatter(object): 44 | """An interface to various tabular data formatting libraries. 45 | 46 | The formatting libraries supported include: 47 | - `tabulate `_ 48 | - `terminaltables `_ 49 | - a CLI Helper vertical table layout 50 | - delimited formats (CSV and TSV) 51 | 52 | :param str format_name: An optional, default format name. 53 | 54 | Usage:: 55 | 56 | >>> from cli_helpers.tabular_output import TabularOutputFormatter 57 | >>> formatter = TabularOutputFormatter(format_name='simple') 58 | >>> data = ((1, 87), (2, 80), (3, 79)) 59 | >>> headers = ('day', 'temperature') 60 | >>> print(formatter.format_output(data, headers)) 61 | day temperature 62 | ----- ------------- 63 | 1 87 64 | 2 80 65 | 3 79 66 | 67 | You can use any :term:`iterable` for the data or headers:: 68 | 69 | >>> data = enumerate(('87', '80', '79'), 1) 70 | >>> print(formatter.format_output(data, headers)) 71 | day temperature 72 | ----- ------------- 73 | 1 87 74 | 2 80 75 | 3 79 76 | 77 | """ 78 | 79 | _output_formats = {} 80 | 81 | def __init__(self, format_name=None): 82 | """Set the default *format_name*.""" 83 | self._format_name = None 84 | 85 | if format_name: 86 | self.format_name = format_name 87 | 88 | @property 89 | def format_name(self): 90 | """The current format name. 91 | 92 | This value must be in :data:`supported_formats`. 93 | 94 | """ 95 | return self._format_name 96 | 97 | @format_name.setter 98 | def format_name(self, format_name): 99 | """Set the default format name. 100 | 101 | :param str format_name: The display format name. 102 | :raises ValueError: if the format is not recognized. 103 | 104 | """ 105 | if format_name in self.supported_formats: 106 | self._format_name = format_name 107 | else: 108 | raise ValueError('unrecognized format_name "{}"'.format(format_name)) 109 | 110 | @property 111 | def supported_formats(self): 112 | """The names of the supported output formats in a :class:`tuple`.""" 113 | return tuple(self._output_formats.keys()) 114 | 115 | @classmethod 116 | def register_new_formatter( 117 | cls, format_name, handler, preprocessors=(), kwargs=None 118 | ): 119 | """Register a new output formatter. 120 | 121 | :param str format_name: The name of the format. 122 | :param callable handler: The function that formats the data. 123 | :param tuple preprocessors: The preprocessors to call before 124 | formatting. 125 | :param dict kwargs: Keys/values for keyword argument defaults. 126 | 127 | """ 128 | cls._output_formats[format_name] = OutputFormatHandler( 129 | format_name, preprocessors, handler, kwargs or {} 130 | ) 131 | 132 | def format_output( 133 | self, 134 | data, 135 | headers, 136 | format_name=None, 137 | preprocessors=(), 138 | column_types=None, 139 | **kwargs 140 | ): 141 | r"""Format the headers and data using a specific formatter. 142 | 143 | *format_name* must be a supported formatter (see 144 | :attr:`supported_formats`). 145 | 146 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 147 | :param iterable headers: The column headers. 148 | :param str format_name: The display format to use (optional, if the 149 | :class:`TabularOutputFormatter` object has a default format set). 150 | :param tuple preprocessors: Additional preprocessors to call before 151 | any formatter preprocessors. 152 | :param \*\*kwargs: Optional arguments for the formatter. 153 | :return: The formatted data. 154 | :rtype: str 155 | :raises ValueError: If the *format_name* is not recognized. 156 | 157 | """ 158 | format_name = format_name or self._format_name 159 | if format_name not in self.supported_formats: 160 | raise ValueError('unrecognized format "{}"'.format(format_name)) 161 | 162 | (_, _preprocessors, formatter, fkwargs) = self._output_formats[format_name] 163 | fkwargs.update(kwargs) 164 | if column_types is None: 165 | data = list(data) 166 | column_types = self._get_column_types(data) 167 | for f in unique_items(preprocessors + _preprocessors): 168 | data, headers = f(data, headers, column_types=column_types, **fkwargs) 169 | return formatter(list(data), headers, column_types=column_types, **fkwargs) 170 | 171 | def _get_column_types(self, data): 172 | """Get a list of the data types for each column in *data*.""" 173 | columns = list(zip_longest(*data)) 174 | return [self._get_column_type(column) for column in columns] 175 | 176 | def _get_column_type(self, column): 177 | """Get the most generic data type for iterable *column*.""" 178 | type_values = [TYPES[self._get_type(v)] for v in column] 179 | inverse_types = {v: k for k, v in TYPES.items()} 180 | return inverse_types[max(type_values)] 181 | 182 | def _get_type(self, value): 183 | """Get the data type for *value*.""" 184 | if value is None: 185 | return type(None) 186 | elif type(value) in int_types: 187 | return int 188 | elif type(value) in float_types: 189 | return float 190 | elif isinstance(value, binary_type): 191 | return binary_type 192 | else: 193 | return text_type 194 | 195 | 196 | def format_output(data, headers, format_name, **kwargs): 197 | r"""Format output using *format_name*. 198 | 199 | This is a wrapper around the :class:`TabularOutputFormatter` class. 200 | 201 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 202 | :param iterable headers: The column headers. 203 | :param str format_name: The display format to use. 204 | :param \*\*kwargs: Optional arguments for the formatter. 205 | :return: The formatted data. 206 | :rtype: str 207 | 208 | """ 209 | formatter = TabularOutputFormatter(format_name=format_name) 210 | return formatter.format_output(data, headers, **kwargs) 211 | 212 | 213 | for vertical_format in vertical_table_adapter.supported_formats: 214 | TabularOutputFormatter.register_new_formatter( 215 | vertical_format, 216 | vertical_table_adapter.adapter, 217 | vertical_table_adapter.preprocessors, 218 | { 219 | "table_format": vertical_format, 220 | "missing_value": MISSING_VALUE, 221 | "max_field_width": None, 222 | }, 223 | ) 224 | 225 | for delimited_format in delimited_output_adapter.supported_formats: 226 | TabularOutputFormatter.register_new_formatter( 227 | delimited_format, 228 | delimited_output_adapter.adapter, 229 | delimited_output_adapter.preprocessors, 230 | { 231 | "table_format": delimited_format, 232 | "missing_value": "", 233 | "max_field_width": None, 234 | }, 235 | ) 236 | 237 | for tabulate_format in tabulate_adapter.supported_formats: 238 | TabularOutputFormatter.register_new_formatter( 239 | tabulate_format, 240 | tabulate_adapter.adapter, 241 | tabulate_adapter.get_preprocessors(tabulate_format), 242 | { 243 | "table_format": tabulate_format, 244 | "missing_value": MISSING_VALUE, 245 | "max_field_width": MAX_FIELD_WIDTH, 246 | }, 247 | ), 248 | 249 | for tsv_format in tsv_output_adapter.supported_formats: 250 | TabularOutputFormatter.register_new_formatter( 251 | tsv_format, 252 | tsv_output_adapter.adapter, 253 | tsv_output_adapter.preprocessors, 254 | {"table_format": tsv_format, "missing_value": "", "max_field_width": None}, 255 | ) 256 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/preprocessors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """These preprocessor functions are used to process data prior to output.""" 3 | 4 | import string 5 | from datetime import datetime 6 | 7 | from cli_helpers import utils 8 | from cli_helpers.compat import text_type, int_types, float_types, HAS_PYGMENTS, Token 9 | 10 | 11 | def truncate_string( 12 | data, headers, max_field_width=None, skip_multiline_string=True, **_ 13 | ): 14 | """Truncate very long strings. Only needed for tabular 15 | representation, because trying to tabulate very long data 16 | is problematic in terms of performance, and does not make any 17 | sense visually. 18 | 19 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 20 | :param iterable headers: The column headers. 21 | :param int max_field_width: Width to truncate field for display 22 | :return: The processed data and headers. 23 | :rtype: tuple 24 | """ 25 | return ( 26 | ( 27 | [ 28 | utils.truncate_string(v, max_field_width, skip_multiline_string) 29 | for v in row 30 | ] 31 | for row in data 32 | ), 33 | [ 34 | utils.truncate_string(h, max_field_width, skip_multiline_string) 35 | for h in headers 36 | ], 37 | ) 38 | 39 | 40 | def convert_to_string(data, headers, **_): 41 | """Convert all *data* and *headers* to strings. 42 | 43 | Binary data that cannot be decoded is converted to a hexadecimal 44 | representation via :func:`binascii.hexlify`. 45 | 46 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 47 | :param iterable headers: The column headers. 48 | :return: The processed data and headers. 49 | :rtype: tuple 50 | 51 | """ 52 | return ( 53 | ([utils.to_string(v) for v in row] for row in data), 54 | [utils.to_string(h) for h in headers], 55 | ) 56 | 57 | 58 | def override_missing_value( 59 | data, 60 | headers, 61 | style=None, 62 | missing_value_token=Token.Output.Null, 63 | missing_value="", 64 | **_, 65 | ): 66 | """Override missing values in the *data* with *missing_value*. 67 | 68 | A missing value is any value that is :data:`None`. 69 | 70 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 71 | :param iterable headers: The column headers. 72 | :param style: Style for missing_value. 73 | :param missing_value_token: The Pygments token used for missing data. 74 | :param missing_value: The default value to use for missing data. 75 | :return: The processed data and headers. 76 | :rtype: tuple 77 | 78 | """ 79 | 80 | def fields(): 81 | for row in data: 82 | processed = [] 83 | for field in row: 84 | if field is None and style and HAS_PYGMENTS: 85 | styled = utils.style_field( 86 | missing_value_token, missing_value, style 87 | ) 88 | processed.append(styled) 89 | elif field is None: 90 | processed.append(missing_value) 91 | else: 92 | processed.append(field) 93 | yield processed 94 | 95 | return (fields(), headers) 96 | 97 | 98 | def override_tab_value(data, headers, new_value=" ", **_): 99 | """Override tab values in the *data* with *new_value*. 100 | 101 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 102 | :param iterable headers: The column headers. 103 | :param new_value: The new value to use for tab. 104 | :return: The processed data and headers. 105 | :rtype: tuple 106 | 107 | """ 108 | return ( 109 | ( 110 | [v.replace("\t", new_value) if isinstance(v, text_type) else v for v in row] 111 | for row in data 112 | ), 113 | headers, 114 | ) 115 | 116 | 117 | def escape_newlines(data, headers, **_): 118 | """Escape newline characters (\n -> \\n, \r -> \\r) 119 | 120 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 121 | :param iterable headers: The column headers. 122 | :return: The processed data and headers. 123 | :rtype: tuple 124 | 125 | """ 126 | return ( 127 | ( 128 | [ 129 | ( 130 | v.replace("\r", r"\r").replace("\n", r"\n") 131 | if isinstance(v, text_type) 132 | else v 133 | ) 134 | for v in row 135 | ] 136 | for row in data 137 | ), 138 | headers, 139 | ) 140 | 141 | 142 | def bytes_to_string(data, headers, **_): 143 | """Convert all *data* and *headers* bytes to strings. 144 | 145 | Binary data that cannot be decoded is converted to a hexadecimal 146 | representation via :func:`binascii.hexlify`. 147 | 148 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 149 | :param iterable headers: The column headers. 150 | :return: The processed data and headers. 151 | :rtype: tuple 152 | 153 | """ 154 | return ( 155 | ([utils.bytes_to_string(v) for v in row] for row in data), 156 | [utils.bytes_to_string(h) for h in headers], 157 | ) 158 | 159 | 160 | def align_decimals(data, headers, column_types=(), **_): 161 | """Align numbers in *data* on their decimal points. 162 | 163 | Whitespace padding is added before a number so that all numbers in a 164 | column are aligned. 165 | 166 | Outputting data before aligning the decimals:: 167 | 168 | 1 169 | 2.1 170 | 10.59 171 | 172 | Outputting data after aligning the decimals:: 173 | 174 | 1 175 | 2.1 176 | 10.59 177 | 178 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 179 | :param iterable headers: The column headers. 180 | :param iterable column_types: The columns' type objects (e.g. int or float). 181 | :return: The processed data and headers. 182 | :rtype: tuple 183 | 184 | """ 185 | pointpos = len(headers) * [0] 186 | data = list(data) 187 | for row in data: 188 | for i, v in enumerate(row): 189 | if column_types[i] is float and type(v) in float_types: 190 | v = text_type(v) 191 | pointpos[i] = max(utils.intlen(v), pointpos[i]) 192 | 193 | def results(data): 194 | for row in data: 195 | result = [] 196 | for i, v in enumerate(row): 197 | if column_types[i] is float and type(v) in float_types: 198 | v = text_type(v) 199 | result.append((pointpos[i] - utils.intlen(v)) * " " + v) 200 | else: 201 | result.append(v) 202 | yield result 203 | 204 | return results(data), headers 205 | 206 | 207 | def quote_whitespaces(data, headers, quotestyle="'", **_): 208 | """Quote leading/trailing whitespace in *data*. 209 | 210 | When outputing data with leading or trailing whitespace, it can be useful 211 | to put quotation marks around the value so the whitespace is more 212 | apparent. If one value in a column needs quoted, then all values in that 213 | column are quoted to keep things consistent. 214 | 215 | .. NOTE:: 216 | :data:`string.whitespace` is used to determine which characters are 217 | whitespace. 218 | 219 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 220 | :param iterable headers: The column headers. 221 | :param str quotestyle: The quotation mark to use (defaults to ``'``). 222 | :return: The processed data and headers. 223 | :rtype: tuple 224 | 225 | """ 226 | whitespace = tuple(string.whitespace) 227 | quote = len(headers) * [False] 228 | data = list(data) 229 | for row in data: 230 | for i, v in enumerate(row): 231 | v = text_type(v) 232 | if v.startswith(whitespace) or v.endswith(whitespace): 233 | quote[i] = True 234 | 235 | def results(data): 236 | for row in data: 237 | result = [] 238 | for i, v in enumerate(row): 239 | quotation = quotestyle if quote[i] else "" 240 | result.append( 241 | "{quotestyle}{value}{quotestyle}".format( 242 | quotestyle=quotation, value=v 243 | ) 244 | ) 245 | yield result 246 | 247 | return results(data), headers 248 | 249 | 250 | def style_output( 251 | data, 252 | headers, 253 | style=None, 254 | header_token=Token.Output.Header, 255 | odd_row_token=Token.Output.OddRow, 256 | even_row_token=Token.Output.EvenRow, 257 | **_, 258 | ): 259 | """Style the *data* and *headers* (e.g. bold, italic, and colors) 260 | 261 | .. NOTE:: 262 | This requires the `Pygments `_ library to 263 | be installed. You can install it with CLI Helpers as an extra:: 264 | $ pip install cli_helpers[styles] 265 | 266 | Example usage:: 267 | 268 | from cli_helpers.tabular_output.preprocessors import style_output 269 | from pygments.style import Style 270 | from pygments.token import Token 271 | 272 | class YourStyle(Style): 273 | default_style = "" 274 | styles = { 275 | Token.Output.Header: 'bold ansibrightred', 276 | Token.Output.OddRow: 'bg:#eee #111', 277 | Token.Output.EvenRow: '#0f0' 278 | } 279 | 280 | headers = ('First Name', 'Last Name') 281 | data = [['Fred', 'Roberts'], ['George', 'Smith']] 282 | 283 | data, headers = style_output(data, headers, style=YourStyle) 284 | 285 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 286 | :param iterable headers: The column headers. 287 | :param str/pygments.style.Style style: A Pygments style. You can `create 288 | your own styles `_. 289 | :param str header_token: The token type to be used for the headers. 290 | :param str odd_row_token: The token type to be used for odd rows. 291 | :param str even_row_token: The token type to be used for even rows. 292 | :return: The styled data and headers. 293 | :rtype: tuple 294 | 295 | """ 296 | from cli_helpers.utils import filter_style_table 297 | 298 | relevant_styles = filter_style_table( 299 | style, header_token, odd_row_token, even_row_token 300 | ) 301 | if style and HAS_PYGMENTS: 302 | if relevant_styles.get(header_token): 303 | headers = [ 304 | utils.style_field(header_token, header, style) for header in headers 305 | ] 306 | if relevant_styles.get(odd_row_token) or relevant_styles.get(even_row_token): 307 | data = ( 308 | [ 309 | utils.style_field( 310 | odd_row_token if i % 2 else even_row_token, f, style 311 | ) 312 | for f in r 313 | ] 314 | for i, r in enumerate(data, 1) 315 | ) 316 | 317 | return iter(data), headers 318 | 319 | 320 | def format_numbers( 321 | data, headers, column_types=(), integer_format=None, float_format=None, **_ 322 | ): 323 | """Format numbers according to a format specification. 324 | 325 | This uses Python's format specification to format numbers of the following 326 | types: :class:`int`, :class:`py2:long` (Python 2), :class:`float`, and 327 | :class:`~decimal.Decimal`. See the :ref:`python:formatspec` for more 328 | information about the format strings. 329 | 330 | .. NOTE:: 331 | A column is only formatted if all of its values are the same type 332 | (except for :data:`None`). 333 | 334 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 335 | :param iterable headers: The column headers. 336 | :param iterable column_types: The columns' type objects (e.g. int or float). 337 | :param str integer_format: The format string to use for integer columns. 338 | :param str float_format: The format string to use for float columns. 339 | :return: The processed data and headers. 340 | :rtype: tuple 341 | 342 | """ 343 | if (integer_format is None and float_format is None) or not column_types: 344 | return iter(data), headers 345 | 346 | def _format_number(field, column_type): 347 | if integer_format and column_type is int and type(field) in int_types: 348 | return format(field, integer_format) 349 | elif float_format and column_type is float and type(field) in float_types: 350 | return format(field, float_format) 351 | return field 352 | 353 | data = ( 354 | [_format_number(v, column_types[i]) for i, v in enumerate(row)] for row in data 355 | ) 356 | return data, headers 357 | 358 | 359 | def format_timestamps(data, headers, column_date_formats=None, **_): 360 | """Format timestamps according to user preference. 361 | 362 | This allows for per-column formatting for date, time, or datetime like data. 363 | 364 | Add a `column_date_formats` section to your config file with separate lines for each column 365 | that you'd like to specify a format using `name=format`. Use standard Python strftime 366 | formatting strings 367 | 368 | Example: `signup_date = "%Y-%m-%d"` 369 | 370 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 371 | :param iterable headers: The column headers. 372 | :param str column_date_format: The format strings to use for specific columns. 373 | :return: The processed data and headers. 374 | :rtype: tuple 375 | 376 | """ 377 | if column_date_formats is None: 378 | return iter(data), headers 379 | 380 | def _format_timestamp(value, name, column_date_formats): 381 | if name not in column_date_formats: 382 | return value 383 | try: 384 | dt = datetime.fromisoformat(value) 385 | return dt.strftime(column_date_formats[name]) 386 | except (ValueError, TypeError): 387 | # not a date 388 | return value 389 | 390 | data = ( 391 | [ 392 | _format_timestamp(v, headers[i], column_date_formats) 393 | for i, v in enumerate(row) 394 | ] 395 | for row in data 396 | ) 397 | return data, headers 398 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/tabulate_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Format adapter for the tabulate module.""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | from cli_helpers.utils import filter_dict_by_key 7 | from cli_helpers.compat import Terminal256Formatter, Token, StringIO 8 | from .preprocessors import ( 9 | convert_to_string, 10 | truncate_string, 11 | override_missing_value, 12 | style_output, 13 | HAS_PYGMENTS, 14 | escape_newlines, 15 | ) 16 | 17 | import tabulate 18 | 19 | 20 | tabulate.MIN_PADDING = 0 21 | 22 | tabulate._table_formats["psql_unicode"] = tabulate.TableFormat( 23 | lineabove=tabulate.Line("┌", "─", "┬", "┐"), 24 | linebelowheader=tabulate.Line("├", "─", "┼", "┤"), 25 | linebetweenrows=None, 26 | linebelow=tabulate.Line("└", "─", "┴", "┘"), 27 | headerrow=tabulate.DataRow("│", "│", "│"), 28 | datarow=tabulate.DataRow("│", "│", "│"), 29 | padding=1, 30 | with_header_hide=None, 31 | ) 32 | 33 | tabulate._table_formats["double"] = tabulate.TableFormat( 34 | lineabove=tabulate.Line("╔", "═", "╦", "╗"), 35 | linebelowheader=tabulate.Line("╠", "═", "╬", "╣"), 36 | linebetweenrows=None, 37 | linebelow=tabulate.Line("╚", "═", "╩", "╝"), 38 | headerrow=tabulate.DataRow("║", "║", "║"), 39 | datarow=tabulate.DataRow("║", "║", "║"), 40 | padding=1, 41 | with_header_hide=None, 42 | ) 43 | 44 | tabulate._table_formats["ascii"] = tabulate.TableFormat( 45 | lineabove=tabulate.Line("+", "-", "+", "+"), 46 | linebelowheader=tabulate.Line("+", "-", "+", "+"), 47 | linebetweenrows=None, 48 | linebelow=tabulate.Line("+", "-", "+", "+"), 49 | headerrow=tabulate.DataRow("|", "|", "|"), 50 | datarow=tabulate.DataRow("|", "|", "|"), 51 | padding=1, 52 | with_header_hide=None, 53 | ) 54 | 55 | tabulate._table_formats["ascii_escaped"] = tabulate.TableFormat( 56 | lineabove=tabulate.Line("+", "-", "+", "+"), 57 | linebelowheader=tabulate.Line("+", "-", "+", "+"), 58 | linebetweenrows=None, 59 | linebelow=tabulate.Line("+", "-", "+", "+"), 60 | headerrow=tabulate.DataRow("|", "|", "|"), 61 | datarow=tabulate.DataRow("|", "|", "|"), 62 | padding=1, 63 | with_header_hide=None, 64 | ) 65 | 66 | # "minimal" is the same as "plain", but without headers 67 | tabulate._table_formats["minimal"] = tabulate._table_formats["plain"] 68 | 69 | tabulate.multiline_formats["psql_unicode"] = "psql_unicode" 70 | tabulate.multiline_formats["double"] = "double" 71 | tabulate.multiline_formats["ascii"] = "ascii" 72 | tabulate.multiline_formats["minimal"] = "minimal" 73 | 74 | supported_markup_formats = ( 75 | "mediawiki", 76 | "html", 77 | "latex", 78 | "latex_booktabs", 79 | "textile", 80 | "moinmoin", 81 | "jira", 82 | ) 83 | supported_table_formats = ( 84 | "ascii", 85 | "ascii_escaped", 86 | "plain", 87 | "simple", 88 | "minimal", 89 | "grid", 90 | "fancy_grid", 91 | "pipe", 92 | "orgtbl", 93 | "psql", 94 | "psql_unicode", 95 | "rst", 96 | "github", 97 | "double", 98 | ) 99 | 100 | supported_formats = supported_markup_formats + supported_table_formats 101 | 102 | default_kwargs = { 103 | "ascii": {"numalign": "left"}, 104 | "ascii_escaped": {"numalign": "left"}, 105 | } 106 | headless_formats = ("minimal",) 107 | 108 | 109 | def get_preprocessors(format_name): 110 | common_formatters = ( 111 | override_missing_value, 112 | convert_to_string, 113 | truncate_string, 114 | style_output, 115 | ) 116 | 117 | if tabulate.multiline_formats.get(format_name): 118 | return common_formatters + (style_output_table(format_name),) 119 | else: 120 | return common_formatters + (escape_newlines, style_output_table(format_name)) 121 | 122 | 123 | def style_output_table(format_name=""): 124 | def style_output( 125 | data, 126 | headers, 127 | style=None, 128 | table_separator_token=Token.Output.TableSeparator, 129 | **_, 130 | ): 131 | """Style the *table* a(e.g. bold, italic, and colors) 132 | 133 | .. NOTE:: 134 | This requires the `Pygments `_ library to 135 | be installed. You can install it with CLI Helpers as an extra:: 136 | $ pip install cli_helpers[styles] 137 | 138 | Example usage:: 139 | 140 | from cli_helpers.tabular_output import tabulate_adapter 141 | from pygments.style import Style 142 | from pygments.token import Token 143 | 144 | class YourStyle(Style): 145 | default_style = "" 146 | styles = { 147 | Token.Output.TableSeparator: '#ansigray' 148 | } 149 | 150 | headers = ('First Name', 'Last Name') 151 | data = [['Fred', 'Roberts'], ['George', 'Smith']] 152 | style_output_table = tabulate_adapter.style_output_table('psql') 153 | style_output_table(data, headers, style=CliStyle) 154 | 155 | data, headers = style_output(data, headers, style=YourStyle) 156 | output = tabulate_adapter.adapter(data, headers, style=YourStyle) 157 | 158 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 159 | :param iterable headers: The column headers. 160 | :param str/pygments.style.Style style: A Pygments style. You can `create 161 | your own styles `_. 162 | :param str table_separator_token: The token type to be used for the table separator. 163 | :return: data and headers. 164 | :rtype: tuple 165 | 166 | """ 167 | if style and HAS_PYGMENTS and format_name in supported_table_formats: 168 | formatter = Terminal256Formatter(style=style) 169 | 170 | def style_field(token, field): 171 | """Get the styled text for a *field* using *token* type.""" 172 | s = StringIO() 173 | formatter.format(((token, field),), s) 174 | return s.getvalue() 175 | 176 | def addColorInElt(elt): 177 | if not elt: 178 | return elt 179 | if elt.__class__ == tabulate.Line: 180 | return tabulate.Line( 181 | *(style_field(table_separator_token, val) for val in elt) 182 | ) 183 | if elt.__class__ == tabulate.DataRow: 184 | return tabulate.DataRow( 185 | *(style_field(table_separator_token, val) for val in elt) 186 | ) 187 | return elt 188 | 189 | srcfmt = tabulate._table_formats[format_name] 190 | newfmt = tabulate.TableFormat(*(addColorInElt(val) for val in srcfmt)) 191 | tabulate._table_formats[format_name] = newfmt 192 | 193 | return iter(data), headers 194 | 195 | return style_output 196 | 197 | 198 | def adapter(data, headers, table_format=None, preserve_whitespace=False, **kwargs): 199 | """Wrap tabulate inside a function for TabularOutputFormatter.""" 200 | keys = ("floatfmt", "numalign", "stralign", "showindex", "disable_numparse") 201 | tkwargs = {"tablefmt": table_format} 202 | tkwargs.update(filter_dict_by_key(kwargs, keys)) 203 | 204 | if table_format in supported_markup_formats: 205 | tkwargs.update(numalign=None, stralign=None) 206 | 207 | tabulate.PRESERVE_WHITESPACE = preserve_whitespace 208 | 209 | tkwargs.update(default_kwargs.get(table_format, {})) 210 | if table_format in headless_formats: 211 | headers = [] 212 | return iter(tabulate.tabulate(data, headers, **tkwargs).split("\n")) 213 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/tsv_output_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A tsv data output adapter""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | from .preprocessors import bytes_to_string, override_missing_value, convert_to_string 7 | from itertools import chain 8 | from cli_helpers.utils import replace 9 | 10 | supported_formats = ("tsv",) 11 | preprocessors = (override_missing_value, bytes_to_string, convert_to_string) 12 | 13 | 14 | def adapter(data, headers, **kwargs): 15 | """Wrap the formatting inside a function for TabularOutputFormatter.""" 16 | for row in chain((headers,), data): 17 | yield "\t".join((replace(r, (("\n", r"\n"), ("\t", r"\t"))) for r in row)) 18 | -------------------------------------------------------------------------------- /cli_helpers/tabular_output/vertical_table_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Format data into a vertical table layout.""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | from cli_helpers.utils import filter_dict_by_key 7 | from .preprocessors import convert_to_string, override_missing_value, style_output 8 | 9 | supported_formats = ("vertical",) 10 | preprocessors = (override_missing_value, convert_to_string, style_output) 11 | 12 | 13 | def _get_separator(num, sep_title, sep_character, sep_length): 14 | """Get a row separator for row *num*.""" 15 | left_divider_length = right_divider_length = sep_length 16 | if isinstance(sep_length, tuple): 17 | left_divider_length, right_divider_length = sep_length 18 | left_divider = sep_character * left_divider_length 19 | right_divider = sep_character * right_divider_length 20 | title = sep_title.format(n=num + 1) 21 | 22 | return "{left_divider}[ {title} ]{right_divider}\n".format( 23 | left_divider=left_divider, right_divider=right_divider, title=title 24 | ) 25 | 26 | 27 | def _format_row(headers, row): 28 | """Format a row.""" 29 | formatted_row = [" | ".join(field) for field in zip(headers, row)] 30 | return "\n".join(formatted_row) 31 | 32 | 33 | def vertical_table( 34 | data, headers, sep_title="{n}. row", sep_character="*", sep_length=27 35 | ): 36 | """Format *data* and *headers* as an vertical table. 37 | 38 | The values in *data* and *headers* must be strings. 39 | 40 | :param iterable data: An :term:`iterable` (e.g. list) of rows. 41 | :param iterable headers: The column headers. 42 | :param str sep_title: The title given to each row separator. Defaults to 43 | ``'{n}. row'``. Any instance of ``'{n}'`` is 44 | replaced by the record number. 45 | :param str sep_character: The character used to separate rows. Defaults to 46 | ``'*'``. 47 | :param int/tuple sep_length: The number of separator characters that should 48 | appear on each side of the *sep_title*. Use 49 | a tuple to specify the left and right values 50 | separately. 51 | :return: The formatted data. 52 | :rtype: str 53 | 54 | """ 55 | header_len = max([len(x) for x in headers]) 56 | padded_headers = [x.ljust(header_len) for x in headers] 57 | formatted_rows = [_format_row(padded_headers, row) for row in data] 58 | 59 | output = [] 60 | for i, result in enumerate(formatted_rows): 61 | yield _get_separator(i, sep_title, sep_character, sep_length) + result 62 | 63 | 64 | def adapter(data, headers, **kwargs): 65 | """Wrap vertical table in a function for TabularOutputFormatter.""" 66 | keys = ("sep_title", "sep_character", "sep_length") 67 | return vertical_table(data, headers, **filter_dict_by_key(kwargs, keys)) 68 | -------------------------------------------------------------------------------- /cli_helpers/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Various utility functions and helpers.""" 3 | 4 | import binascii 5 | import re 6 | from functools import lru_cache 7 | from typing import Dict 8 | 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from pygments.style import StyleMeta 13 | 14 | from cli_helpers.compat import binary_type, text_type, Terminal256Formatter, StringIO 15 | 16 | 17 | def bytes_to_string(b): 18 | """Convert bytes *b* to a string. 19 | 20 | Hexlify bytes that can't be decoded. 21 | 22 | """ 23 | if isinstance(b, binary_type): 24 | needs_hex = False 25 | try: 26 | result = b.decode("utf8") 27 | needs_hex = not result.isprintable() 28 | except UnicodeDecodeError: 29 | needs_hex = True 30 | if needs_hex: 31 | return "0x" + binascii.hexlify(b).decode("ascii") 32 | else: 33 | return result 34 | return b 35 | 36 | 37 | def to_string(value): 38 | """Convert *value* to a string.""" 39 | if isinstance(value, binary_type): 40 | return bytes_to_string(value) 41 | else: 42 | return text_type(value) 43 | 44 | 45 | def truncate_string(value, max_width=None, skip_multiline_string=True): 46 | """Truncate string values.""" 47 | if skip_multiline_string and isinstance(value, text_type) and "\n" in value: 48 | return value 49 | elif ( 50 | isinstance(value, text_type) 51 | and max_width is not None 52 | and len(value) > max_width 53 | ): 54 | return value[: max_width - 3] + "..." 55 | return value 56 | 57 | 58 | def intlen(n): 59 | """Find the length of the integer part of a number *n*.""" 60 | pos = n.find(".") 61 | return len(n) if pos < 0 else pos 62 | 63 | 64 | def filter_dict_by_key(d, keys): 65 | """Filter the dict *d* to remove keys not in *keys*.""" 66 | return {k: v for k, v in d.items() if k in keys} 67 | 68 | 69 | def unique_items(seq): 70 | """Return the unique items from iterable *seq* (in order).""" 71 | seen = set() 72 | return [x for x in seq if not (x in seen or seen.add(x))] 73 | 74 | 75 | _ansi_re = re.compile("\033\\[((?:\\d|;)*)([a-zA-Z])") 76 | 77 | 78 | def strip_ansi(value): 79 | """Strip the ANSI escape sequences from a string.""" 80 | return _ansi_re.sub("", value) 81 | 82 | 83 | def replace(s, replace): 84 | """Replace multiple values in a string""" 85 | for r in replace: 86 | s = s.replace(*r) 87 | return s 88 | 89 | 90 | @lru_cache() 91 | def _get_formatter(style) -> Terminal256Formatter: 92 | return Terminal256Formatter(style=style) 93 | 94 | 95 | def style_field(token, field, style): 96 | """Get the styled text for a *field* using *token* type.""" 97 | formatter = _get_formatter(style) 98 | s = StringIO() 99 | formatter.format(((token, field),), s) 100 | return s.getvalue() 101 | 102 | 103 | def filter_style_table(style: "StyleMeta", *relevant_styles: str) -> Dict: 104 | """ 105 | get a dictionary of styles for given tokens. Typical usage: 106 | 107 | filter_style_table(style, Token.Output.EvenRow, Token.Output.OddRow) == { 108 | Token.Output.EvenRow: "", 109 | Token.Output.OddRow: "", 110 | } 111 | """ 112 | _styles_iter = ((key, val) for key, val in getattr(style, "styles", {}).items()) 113 | _relevant_styles_iter = filter(lambda tpl: tpl[0] in relevant_styles, _styles_iter) 114 | return {key: val for key, val in _relevant_styles_iter} 115 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = CLIHelpers 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. automodule:: cli_helpers 5 | 6 | Tabular Output 7 | -------------- 8 | 9 | .. automodule:: cli_helpers.tabular_output 10 | :members: 11 | :imported-members: 12 | 13 | Preprocessors 14 | +++++++++++++ 15 | 16 | .. automodule:: cli_helpers.tabular_output.preprocessors 17 | :members: 18 | 19 | Config 20 | ------ 21 | 22 | .. automodule:: cli_helpers.config 23 | :members: 24 | -------------------------------------------------------------------------------- /docs/source/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../AUTHORS 2 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # CLI Helpers documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Apr 17 20:26:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import ast 21 | from collections import OrderedDict 22 | 23 | # import os 24 | import re 25 | 26 | # import sys 27 | # sys.path.insert(0, os.path.abspath('.')) 28 | 29 | 30 | # -- General configuration ------------------------------------------------ 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewcode"] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ["_templates"] 43 | 44 | html_sidebars = { 45 | "**": [ 46 | "about.html", 47 | "navigation.html", 48 | "relations.html", 49 | "searchbox.html", 50 | "donate.html", 51 | ] 52 | } 53 | 54 | # The suffix(es) of source filenames. 55 | # You can specify multiple suffix as a list of string: 56 | # 57 | # source_suffix = ['.rst', '.md'] 58 | source_suffix = ".rst" 59 | 60 | # The master toctree document. 61 | master_doc = "index" 62 | 63 | # General information about the project. 64 | project = "CLI Helpers" 65 | author = "dbcli" 66 | description = "Python helpers for common CLI tasks" 67 | copyright = "2017, dbcli" 68 | 69 | # The version info for the project you're documenting, acts as replacement for 70 | # |version| and |release|, also used in various other places throughout the 71 | # built documents. 72 | # 73 | _version_re = re.compile(r"__version__\s+=\s+(.*)") 74 | with open("../../cli_helpers/__init__.py", "rb") as f: 75 | version = str( 76 | ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) 77 | ) 78 | 79 | # The full version, including alpha/beta/rc tags. 80 | release = version 81 | 82 | # The language for content autogenerated by Sphinx. Refer to documentation 83 | # for a list of supported languages. 84 | # 85 | # This is also used if you do content translation via gettext catalogs. 86 | # Usually you set "language" from the command line for these cases. 87 | language = None 88 | 89 | # List of patterns, relative to source directory, that match files and 90 | # directories to ignore when looking for source files. 91 | # This patterns also effect to html_static_path and html_extra_path 92 | exclude_patterns = [] 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = "sphinx" 96 | 97 | # If true, `todo` and `todoList` produce output, else they produce nothing. 98 | todo_include_todos = False 99 | 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | # 106 | html_theme = "alabaster" 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | 112 | nav_links = OrderedDict( 113 | ( 114 | ("CLI Helpers at GitHub", "https://github.com/dbcli/cli_helpers"), 115 | ("CLI Helpers at PyPI", "https://pypi.org/project/cli_helpers"), 116 | ("Issue Tracker", "https://github.com/dbcli/cli_helpers/issues"), 117 | ) 118 | ) 119 | 120 | html_theme_options = { 121 | "description": description, 122 | "github_user": "dbcli", 123 | "github_repo": "cli_helpers", 124 | "github_banner": False, 125 | "github_button": False, 126 | "github_type": "watch", 127 | "github_count": False, 128 | "extra_nav_links": nav_links, 129 | } 130 | 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | html_static_path = ["_static"] 136 | 137 | 138 | # -- Options for HTMLHelp output ------------------------------------------ 139 | 140 | # Output file base name for HTML help builder. 141 | htmlhelp_basename = "CLIHelpersdoc" 142 | 143 | 144 | # -- Options for LaTeX output --------------------------------------------- 145 | 146 | latex_elements = { 147 | # The paper size ('letterpaper' or 'a4paper'). 148 | # 149 | # 'papersize': 'letterpaper', 150 | # The font size ('10pt', '11pt' or '12pt'). 151 | # 152 | # 'pointsize': '10pt', 153 | # Additional stuff for the LaTeX preamble. 154 | # 155 | # 'preamble': '', 156 | # Latex figure (float) alignment 157 | # 158 | # 'figure_align': 'htbp', 159 | } 160 | 161 | # Grouping the document tree into LaTeX files. List of tuples 162 | # (source start file, target name, title, 163 | # author, documentclass [howto, manual, or own class]). 164 | latex_documents = [ 165 | (master_doc, "CLIHelpers.tex", "CLI Helpers Documentation", "dbcli", "manual"), 166 | ] 167 | 168 | 169 | # -- Options for manual page output --------------------------------------- 170 | 171 | # One entry per manual page. List of tuples 172 | # (source start file, name, description, authors, manual section). 173 | man_pages = [(master_doc, "clihelpers", "CLI Helpers Documentation", [author], 1)] 174 | 175 | 176 | # -- Options for Texinfo output ------------------------------------------- 177 | 178 | # Grouping the document tree into Texinfo files. List of tuples 179 | # (source start file, target name, title, author, 180 | # dir menu entry, description, category) 181 | texinfo_documents = [ 182 | ( 183 | master_doc, 184 | "CLIHelpers", 185 | "CLI Helpers Documentation", 186 | author, 187 | "CLIHelpers", 188 | description, 189 | "Miscellaneous", 190 | ), 191 | ] 192 | 193 | 194 | intersphinx_mapping = { 195 | "python": ("https://docs.python.org/3", None), 196 | "py2": ("https://docs.python.org/2", None), 197 | "pymysql": ("https://pymysql.readthedocs.io/en/latest/", None), 198 | "numpy": ("https://docs.scipy.org/doc/numpy", None), 199 | "configobj": ("https://configobj.readthedocs.io/en/latest", None), 200 | } 201 | 202 | linkcheck_ignore = ["https://github.com/psf/black.*"] 203 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to CLI Helpers 2 | ====================== 3 | 4 | .. include:: ../../README.rst 5 | :start-after: start-body 6 | :end-before: end-body 7 | 8 | Installation 9 | ------------ 10 | You can get the library directly from `PyPI `_:: 11 | 12 | $ pip install cli_helpers 13 | 14 | User Guide 15 | ---------- 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | quickstart 20 | contributing 21 | changelog 22 | authors 23 | license 24 | 25 | API 26 | --- 27 | .. toctree:: 28 | :maxdepth: 2 29 | 30 | api 31 | -------------------------------------------------------------------------------- /docs/source/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | CLI Helpers is licensed under the BSD 3-clause license. This basically means 5 | you can do what you'd like with the source code as long as you include a copy 6 | of the license, don't modify the conditions, and keep the disclaimer around. 7 | Plus, you can't use the authors' names to promote your software without their 8 | written consent. 9 | 10 | License Text 11 | ++++++++++++ 12 | 13 | .. include:: ../../LICENSE 14 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Displaying Tabular Data 5 | ----------------------- 6 | 7 | 8 | The Basics 9 | ++++++++++ 10 | 11 | CLI Helpers provides a simple way to display your tabular data (columns/rows) in a visually-appealing manner:: 12 | 13 | >>> from cli_helpers import tabular_output 14 | 15 | >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] 16 | >>> headers = ['id', 'city', 'visited'] 17 | 18 | >>> print("\n".join(tabular_output.format_output(iter(data), headers, format_name='simple'))) 19 | 20 | id city visited 21 | ---- --------- --------- 22 | 1 Asgard True 23 | 2 Camelot False 24 | 3 El Dorado True 25 | 26 | Let's take a look at what we did there. 27 | 28 | 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. 29 | 30 | 2. Next we generate some data. Plus, we need a list of headers to give our data some context. 31 | 32 | 3. We format the output using the display format ``simple``. That's a nice looking table! 33 | 34 | 35 | Display Formats 36 | +++++++++++++++ 37 | 38 | To display your data, :mod:`~cli_helpers.tabular_output` uses 39 | `tabulate `_, 40 | `terminaltables `_, :mod:`csv`, 41 | and its own vertical table layout. 42 | 43 | The best way to see the various display formats is to use the 44 | :class:`~cli_helpers.tabular_output.TabularOutputFormatter` class. This is 45 | what the :func:`~cli_helpers.tabular_output.format_output` function in our 46 | first example uses behind the scenes. 47 | 48 | Let's get a list of all the supported format names:: 49 | 50 | >>> from cli_helpers.tabular_output import TabularOutputFormatter 51 | >>> formatter = TabularOutputFormatter() 52 | >>> formatter.supported_formats 53 | ('vertical', 'csv', 'tsv', 'mediawiki', 'html', 'latex', 'latex_booktabs', 'textile', 'moinmoin', 'jira', 'plain', 'minimal', 'simple', 'grid', 'fancy_grid', 'pipe', 'orgtbl', 'psql', 'psql_unicode', 'rst', 'ascii', 'double', 'github') 54 | 55 | You can format your data in any of those supported formats. Let's take the 56 | same data from our first example and put it in the ``fancy_grid`` format:: 57 | 58 | >>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]] 59 | >>> headers = ['id', 'city', 'visited'] 60 | >>> print("\n".join(formatter.format_output(iter(data), headers, format_name='fancy_grid'))) 61 | ╒══════╤═══════════╤═══════════╕ 62 | │ id │ city │ visited │ 63 | ╞══════╪═══════════╪═══════════╡ 64 | │ 1 │ Asgard │ True │ 65 | ├──────┼───────────┼───────────┤ 66 | │ 2 │ Camelot │ False │ 67 | ├──────┼───────────┼───────────┤ 68 | │ 3 │ El Dorado │ True │ 69 | ╘══════╧═══════════╧═══════════╛ 70 | 71 | That was easy! How about CLI Helper's vertical table layout? 72 | 73 | >>> print("\n".join(formatter.format_output(iter(data), headers, format_name='vertical'))) 74 | ***************************[ 1. row ]*************************** 75 | id | 1 76 | city | Asgard 77 | visited | True 78 | ***************************[ 2. row ]*************************** 79 | id | 2 80 | city | Camelot 81 | visited | False 82 | ***************************[ 3. row ]*************************** 83 | id | 3 84 | city | El Dorado 85 | visited | True 86 | 87 | 88 | Default Format 89 | ++++++++++++++ 90 | 91 | When you create a :class:`~cli_helpers.tabular_output.TabularOutputFormatter` 92 | object, you can specify a default formatter so you don't have to pass the 93 | format name each time you want to format your data:: 94 | 95 | >>> formatter = TabularOutputFormatter(format_name='plain') 96 | >>> print("\n".join(formatter.format_output(iter(data), headers))) 97 | id city visited 98 | 1 Asgard True 99 | 2 Camelot False 100 | 3 El Dorado True 101 | 102 | .. TIP:: 103 | You can get or set the default format whenever you'd like through 104 | :data:`TabularOutputFormatter.format_name `. 105 | 106 | 107 | Passing Options to the Formatters 108 | +++++++++++++++++++++++++++++++++ 109 | 110 | Many of the formatters have settings that can be tweaked by passing 111 | an optional argument when you format your data. For example, 112 | if we wanted to enable or disable number parsing on any of 113 | `tabulate's `_ 114 | formats, we could:: 115 | 116 | >>> data = [[1, 1.5], [2, 19.605], [3, 100.0]] 117 | >>> headers = ['id', 'rating'] 118 | >>> print("\n".join(format_output(iter(data), headers, format_name='simple', disable_numparse=True))) 119 | id rating 120 | ---- -------- 121 | 1 1.5 122 | 2 19.605 123 | 3 100.0 124 | >>> print("\n".join(format_output(iter(data), headers, format_name='simple', disable_numparse=False))) 125 | id rating 126 | ---- -------- 127 | 1 1.5 128 | 2 19.605 129 | 3 100 130 | 131 | 132 | Lists and tuples and bytearrays. Oh my! 133 | +++++++++++++++++++++++++++++++++++++++ 134 | 135 | :mod:`~cli_helpers.tabular_output` supports any :term:`iterable`, not just 136 | a :class:`list` or :class:`tuple`. You can use a :class:`range`, 137 | :func:`enumerate`, a :class:`str`, or even a :class:`bytearray`! Here is a 138 | far-fetched example to prove the point:: 139 | 140 | >>> step = 3 141 | >>> data = [range(n, n + step) for n in range(0, 9, step)] 142 | >>> headers = 'abc' 143 | >>> print("\n".join(format_output(iter(data), headers, format_name='simple'))) 144 | a b c 145 | --- --- --- 146 | 0 1 2 147 | 3 4 5 148 | 6 7 8 149 | 150 | Real life examples include a PyMySQL 151 | :class:`Cursor ` with 152 | database results or 153 | NumPy :class:`ndarray ` with data points. 154 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """A script to publish a release of cli_helpers to PyPI.""" 3 | 4 | import io 5 | from optparse import OptionParser 6 | import re 7 | import subprocess 8 | import sys 9 | 10 | import click 11 | 12 | DEBUG = False 13 | CONFIRM_STEPS = False 14 | DRY_RUN = False 15 | 16 | 17 | def skip_step(): 18 | """ 19 | Asks for user's response whether to run a step. Default is yes. 20 | :return: boolean 21 | """ 22 | global CONFIRM_STEPS 23 | 24 | if CONFIRM_STEPS: 25 | return not click.confirm("--- Run this step?", default=True) 26 | return False 27 | 28 | 29 | def run_step(*args): 30 | """ 31 | Prints out the command and asks if it should be run. 32 | If yes (default), runs it. 33 | :param args: list of strings (command and args) 34 | """ 35 | global DRY_RUN 36 | 37 | cmd = args 38 | print(" ".join(cmd)) 39 | if skip_step(): 40 | print("--- Skipping...") 41 | elif DRY_RUN: 42 | print("--- Pretending to run...") 43 | else: 44 | subprocess.check_output(cmd) 45 | 46 | 47 | def version(version_file): 48 | _version_re = re.compile( 49 | r'__version__\s+=\s+(?P[\'"])(?P.*)(?P=quote)' 50 | ) 51 | 52 | with io.open(version_file, encoding="utf-8") as f: 53 | ver = _version_re.search(f.read()).group("version") 54 | 55 | return ver 56 | 57 | 58 | def commit_for_release(version_file, ver): 59 | run_step("git", "reset") 60 | run_step("git", "add", version_file) 61 | run_step("git", "commit", "--message", "Releasing version {}".format(ver)) 62 | 63 | 64 | def create_git_tag(tag_name): 65 | run_step("git", "tag", tag_name) 66 | 67 | 68 | def create_distribution_files(): 69 | run_step("python", "setup.py", "clean", "--all", "sdist", "bdist_wheel") 70 | 71 | 72 | def upload_distribution_files(): 73 | run_step("twine", "upload", "dist/*") 74 | 75 | 76 | def push_to_github(): 77 | run_step("git", "push", "origin", "master") 78 | 79 | 80 | def push_tags_to_github(): 81 | run_step("git", "push", "--tags", "origin") 82 | 83 | 84 | def checklist(questions): 85 | for question in questions: 86 | if not click.confirm("--- {}".format(question), default=False): 87 | sys.exit(1) 88 | 89 | 90 | if __name__ == "__main__": 91 | if DEBUG: 92 | subprocess.check_output = lambda x: x 93 | 94 | checks = [ 95 | "Have you updated the AUTHORS file?", 96 | "Have you updated the `Usage` section of the README?", 97 | ] 98 | checklist(checks) 99 | 100 | ver = version("cli_helpers/__init__.py") 101 | print("Releasing Version:", ver) 102 | 103 | parser = OptionParser() 104 | parser.add_option( 105 | "-c", 106 | "--confirm-steps", 107 | action="store_true", 108 | dest="confirm_steps", 109 | default=False, 110 | help=( 111 | "Confirm every step. If the step is not " "confirmed, it will be skipped." 112 | ), 113 | ) 114 | parser.add_option( 115 | "-d", 116 | "--dry-run", 117 | action="store_true", 118 | dest="dry_run", 119 | default=False, 120 | help="Print out, but not actually run any steps.", 121 | ) 122 | 123 | popts, pargs = parser.parse_args() 124 | CONFIRM_STEPS = popts.confirm_steps 125 | DRY_RUN = popts.dry_run 126 | 127 | if not click.confirm("Are you sure?", default=False): 128 | sys.exit(1) 129 | 130 | commit_for_release("cli_helpers/__init__.py", ver) 131 | create_git_tag("v{}".format(ver)) 132 | create_distribution_files() 133 | push_to_github() 134 | push_tags_to_github() 135 | upload_distribution_files() 136 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | autopep8==1.3.3 2 | codecov==2.1.13 3 | coverage==4.3.4 4 | black>=20.8b1 5 | Pygments>=2.4.0 6 | pytest==7.4.3 7 | pytest-cov==2.4.0 8 | Sphinx==1.5.5 9 | tox==2.7.0 10 | twine==1.12.1 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = cli_helpers 3 | omit = cli_helpers/packages/*.py 4 | 5 | [check-manifest] 6 | ignore = 7 | appveyor.yml 8 | .travis.yml 9 | .github* 10 | .travis* 11 | 12 | [tool:pytest] 13 | testpaths = tests 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import ast 5 | from io import open 6 | import re 7 | import sys 8 | 9 | from setuptools import find_packages, setup 10 | 11 | _version_re = re.compile(r"__version__\s+=\s+(.*)") 12 | 13 | with open("cli_helpers/__init__.py", "rb") as f: 14 | version = str( 15 | ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) 16 | ) 17 | 18 | 19 | def open_file(filename): 20 | """Open and read the file *filename*.""" 21 | with open(filename) as f: 22 | return f.read() 23 | 24 | 25 | readme = open_file("README.rst") 26 | 27 | setup( 28 | name="cli_helpers", 29 | author="dbcli", 30 | author_email="thomas@roten.us", 31 | version=version, 32 | url="https://github.com/dbcli/cli_helpers", 33 | packages=find_packages(exclude=["docs", "tests", "tests.tabular_output"]), 34 | include_package_data=True, 35 | description="Helpers for building command-line apps", 36 | long_description=readme, 37 | long_description_content_type="text/x-rst", 38 | install_requires=[ 39 | "configobj >= 5.0.5", 40 | "tabulate[widechars] >= 0.9.0", 41 | ], 42 | extras_require={ 43 | "styles": ["Pygments >= 1.6"], 44 | }, 45 | python_requires=">=3.6", 46 | classifiers=[ 47 | "Intended Audience :: Developers", 48 | "License :: OSI Approved :: BSD License", 49 | "Operating System :: Unix", 50 | "Programming Language :: Python", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.6", 53 | "Programming Language :: Python :: 3.7", 54 | "Topic :: Software Development", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | "Topic :: Terminals :: Terminal Emulators/X Terminals", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Common development tasks for setup.py to use.""" 3 | 4 | import re 5 | import subprocess 6 | import sys 7 | 8 | from setuptools import Command 9 | 10 | 11 | class BaseCommand(Command, object): 12 | """The base command for project tasks.""" 13 | 14 | user_options = [] 15 | 16 | default_cmd_options = ("verbose", "quiet", "dry_run") 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(BaseCommand, self).__init__(*args, **kwargs) 20 | self.verbose = False 21 | 22 | def initialize_options(self): 23 | """Override the distutils abstract method.""" 24 | pass 25 | 26 | def finalize_options(self): 27 | """Override the distutils abstract method.""" 28 | # Distutils uses incrementing integers for verbosity. 29 | self.verbose = bool(self.verbose) 30 | 31 | def call_and_exit(self, cmd, shell=True): 32 | """Run the *cmd* and exit with the proper exit code.""" 33 | sys.exit(subprocess.call(cmd, shell=shell)) 34 | 35 | def call_in_sequence(self, cmds, shell=True): 36 | """Run multiple commmands in a row, exiting if one fails.""" 37 | for cmd in cmds: 38 | if subprocess.call(cmd, shell=shell) == 1: 39 | sys.exit(1) 40 | 41 | def apply_options(self, cmd, options=()): 42 | """Apply command-line options.""" 43 | for option in self.default_cmd_options + options: 44 | cmd = self.apply_option(cmd, option, active=getattr(self, option, False)) 45 | return cmd 46 | 47 | def apply_option(self, cmd, option, active=True): 48 | """Apply a command-line option.""" 49 | return re.sub( 50 | r"{{{}\:(?P