├── .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