1
0
Fork 0

Adding upstream version 2.1.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-07 00:47:33 +01:00
parent d1aeef90c9
commit d8a70e48ab
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
56 changed files with 3865 additions and 0 deletions

9
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,9 @@
## Description
<!--- Describe your changes in detail. -->
## Checklist
<!--- We appreciate your help and want to give you credit. Please take a moment to put an `x` in the boxes below as you complete them. -->
- [ ] I've added this contribution to the `CHANGELOG`.
- [ ] I've added my name to the `AUTHORS` file (or it's already there).

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
__pycache__
*.pyc
*.egg
*.egg-info
.coverage
/.tox
/build
/docs/build
/dist
/cli_helpers.egg-info
/cli_helpers_dev
.idea/
.cache/

30
.travis.yml Normal file
View file

@ -0,0 +1,30 @@
sudo: false
language: python
cache: pip
install: ./.travis/install.sh
script:
- source ~/.venv/bin/activate
- tox
matrix:
include:
- os: linux
python: 3.6
env: TOXENV=py36
- os: linux
python: 3.6
env: TOXENV=noextras
- os: linux
python: 3.6
env: TOXENV=docs
- os: linux
python: 3.6
env: TOXENV=packaging
- os: osx
language: generic
env: TOXENV=py36
- os: linux
python: 3.7
env: TOXENV=py37
dist: xenial
sudo: true

25
.travis/install.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/bash
set -ex
if [[ "$(uname -s)" == 'Darwin' ]]; then
sw_vers
git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
case "${TOXENV}" in
py36)
pyenv install 3.6.1
pyenv global 3.6.1
;;
esac
pyenv rehash
fi
pip install virtualenv
python -m virtualenv ~/.venv
source ~/.venv/bin/activate
pip install tox

29
AUTHORS Normal file
View file

@ -0,0 +1,29 @@
Authors
=======
CLI Helpers is written and maintained by the following people:
- Amjith Ramanujam
- Dick Marinus
- Irina Truong
- Thomas Roten
Contributors
------------
This project receives help from these awesome contributors:
- Terje Røsten
- Frederic Aoustin
- Zhaolong Zhu
- Karthikeyan Singaravelan
- laixintao
- Georgy Frolov
- Michał Górny
Thanks
------
This project exists because of the amazing contributors from
`pgcli <https://pgcli.com/>`_ and `mycli <http://mycli.net/>`_.

133
CHANGELOG Normal file
View file

@ -0,0 +1,133 @@
Changelog
=========
Version 2.1.0
-------------
(released on 2020-07-29)
* Speed up ouput styling of tables.
Version 2.0.1
-------------
(released on 2020-05-27)
* Fix newline escaping in plain-text formatters (ascii, double, github)
* Use built-in unittest.mock instead of mock.
Version 2.0.0
-------------
(released on 2020-05-26)
* Remove Python 2.7 and 3.5.
* Style config for missing value.
Version 1.2.1
-------------
(released on 2019-06-09)
* Pin Pygments to >= 2.4.0 for tests.
* Remove Python 3.4 from tests and Trove classifier.
* Add an option to skip truncating multi-line strings.
* When truncating long strings, add ellipsis.
Version 1.2.0
-------------
(released on 2019-04-05)
* Run tests on Python 3.7.
* Use twine check during packaging tests.
* Rename old tsv format to csv-tab (because it add quotes), introduce new tsv output adapter.
* Truncate long fields for tabular display.
* Return the supported table formats as unicode.
* Override tab with 4 spaces for terminal tables.
Version 1.1.0
-------------
(released on 2018-10-18)
* Adds config file reading/writing.
* Style formatted tables with Pygments (optional).
Version 1.0.2
-------------
(released on 2018-04-07)
* Copy unit test from pgcli
* Use safe float for unit test
* Move strip_ansi from tests.utils to cli_helpers.utils
Version 1.0.1
-------------
(released on 2017-11-27)
* Output all unicode for terminaltables, add unit test.
Version 1.0.0
-------------
(released on 2017-10-11)
* Output as generator
* Use backports.csv only for py2
* Require tabulate as a dependency instead of using vendored module.
* Drop support for Python 3.3.
Version 0.2.3
-------------
(released on 2017-08-01)
* Fix unicode error on Python 2 with newlines in output row.
* Fixes to accept iterator.
Version 0.2.2
-------------
(released on 2017-07-16)
* Fix IndexError from being raised with uneven rows.
Version 0.2.1
-------------
(released on 2017-07-11)
* Run tests on macOS via Travis.
* Fix unicode issues on Python 2 (csv and styling output).
Version 0.2.0
-------------
(released on 2017-06-23)
* Make vertical table separator more customizable.
* Add format numbers preprocessor.
* Add test coverage reports.
* Add ability to pass additional preprocessors when formatting output.
* Don't install tests.tabular_output.
* Add .gitignore
* Coverage for tox tests.
* Style formatted output with Pygments (optional).
* Fix issue where tabulate can't handle ANSI escape codes in default values.
* Run tests on Windows via Appveyor.
Version 0.1.0
-------------
(released on 2017-05-01)
* Pretty print tabular data using a variety of formatting libraries.

102
CONTRIBUTING.rst Normal file
View file

@ -0,0 +1,102 @@
How to Contribute
=================
CLI Helpers would love your help! We appreciate your time and always give credit.
Development Setup
-----------------
Ready to contribute? Here's how to set up CLI Helpers for local development.
1. `Fork the repository <https://github.com/dbcli/cli_helpers>`_ on GitHub.
2. Clone your fork locally::
$ git clone <url-for-your-fork>
3. Add the official repository (``upstream``) as a remote repository::
$ git remote add upstream git@github.com:dbcli/cli_helpers.git
4. Set up a `virtual environment <http://docs.python-guide.org/en/latest/dev/virtualenvs>`_
for development::
$ cd cli_helpers
$ pip install virtualenv
$ virtualenv cli_helpers_dev
We've just created a virtual environment that we'll use to install all the dependencies
and tools we need to work on CLI Helpers. Whenever you want to work on CLI Helpers, you
need to activate the virtual environment::
$ source cli_helpers_dev/bin/activate
When you're done working, you can deactivate the virtual environment::
$ deactivate
5. Install the dependencies and development tools::
$ pip install -r requirements-dev.txt
$ pip install --editable .
6. Create a branch for your bugfix or feature based off the ``master`` branch::
$ git checkout -b <name-of-bugfix-or-feature> master
7. While you work on your bugfix or feature, be sure to pull the latest changes from ``upstream``. This ensures that your local codebase is up-to-date::
$ git pull upstream master
8. When your work is ready for the CLI Helpers team to review it, push your branch to your fork::
$ git push origin <name-of-bugfix-or-feature>
9. `Create a pull request <https://help.github.com/articles/creating-a-pull-request-from-a-fork/>`_
on GitHub.
Running the Tests
-----------------
While you work on CLI Helpers, it's important to run the tests to make sure your code
hasn't broken any existing functionality. To run the tests, just type in::
$ pytest
CLI Helpers supports Python 3.6+. You can test against multiple versions of
Python by running::
$ tox
You can also measure CLI Helper's test coverage by running::
$ pytest --cov-report= --cov=cli_helpers
$ coverage report
Coding Style
------------
CLI Helpers requires code submissions to adhere to
`PEP 8 <https://www.python.org/dev/peps/pep-0008/>`_.
It's easy to check the style of your code, just run::
$ pep8radius master
If you see any PEP 8 style issues, you can automatically fix them by running::
$ pep8radius master --in-place
Be sure to commit and push any PEP 8 fixes.
Documentation
-------------
If your work in CLI Helpers requires a documentation change or addition, you can
build the documentation by running::
$ make -C docs clean html
$ open docs/build/html/index.html
That will build the documentation and open it in your web browser.

27
LICENSE Normal file
View file

@ -0,0 +1,27 @@
Copyright (c) 2017, dbcli
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of dbcli nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

8
MANIFEST.in Normal file
View file

@ -0,0 +1,8 @@
include *.txt *.rst *.py
include AUTHORS CHANGELOG LICENSE
include tox.ini
recursive-include docs *.py
recursive-include docs *.rst
recursive-include docs Makefile
recursive-include tests *.py
include tests/config_data/*

38
README.rst Normal file
View file

@ -0,0 +1,38 @@
===========
CLI Helpers
===========
.. image:: https://travis-ci.org/dbcli/cli_helpers.svg?branch=master
:target: https://travis-ci.org/dbcli/cli_helpers
.. image:: https://ci.appveyor.com/api/projects/status/37a1ri2nbcp237tr/branch/master?svg=true
:target: https://ci.appveyor.com/project/dbcli/cli-helpers
.. image:: https://codecov.io/gh/dbcli/cli_helpers/branch/master/graph/badge.svg
:target: https://codecov.io/gh/dbcli/cli_helpers
.. image:: https://img.shields.io/pypi/v/cli_helpers.svg?style=flat
:target: https://pypi.python.org/pypi/cli_helpers
.. start-body
CLI Helpers is a Python package that makes it easy to perform common tasks when
building command-line apps. It's a helper library for command-line interfaces.
Libraries like `Click <http://click.pocoo.org/5/>`_ and
`Python Prompt Toolkit <https://python-prompt-toolkit.readthedocs.io/en/latest/>`_
are amazing tools that help you create quality apps. CLI Helpers complements
these libraries by wrapping up common tasks in simple interfaces.
CLI Helpers is not focused on your app's design pattern or framework -- you can
use it on its own or in combination with other libraries. It's lightweight and
easy to extend.
What's included in CLI Helpers?
- Prettyprinting of tabular data with custom pre-processing
- Config file reading/writing
.. end-body
Read the documentation at http://cli-helpers.rtfd.io

15
appveyor.yml Normal file
View file

@ -0,0 +1,15 @@
environment:
matrix:
- PYTHON: "C:\\Python36"
- PYTHON: "C:\\Python37"
build: off
before_test:
- "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
- pip install -r requirements-dev.txt
- pip install -e .
test_script:
- pytest --cov-report= --cov=cli_helpers
- coverage report
- codecov

1
cli_helpers/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = '2.1.0'

42
cli_helpers/compat.py Normal file
View file

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""OS and Python compatibility support."""
from decimal import Decimal
import sys
PY2 = sys.version_info[0] == 2
WIN = sys.platform.startswith('win')
MAC = sys.platform == 'darwin'
if PY2:
text_type = unicode
binary_type = str
long_type = long
int_types = (int, long)
from UserDict import UserDict
from backports import csv
from StringIO import StringIO
from itertools import izip_longest as zip_longest
else:
text_type = str
binary_type = bytes
long_type = int
int_types = (int,)
from collections import UserDict
import csv
from io import StringIO
from itertools import zip_longest
HAS_PYGMENTS = True
try:
from pygments.formatters.terminal256 import Terminal256Formatter
except ImportError:
HAS_PYGMENTS = False
Terminal256Formatter = None
float_types = (float, Decimal)

270
cli_helpers/config.py Normal file
View file

@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
"""Read and write an application's config files."""
from __future__ import unicode_literals
import io
import logging
import os
from configobj import ConfigObj, ConfigObjError
from validate import ValidateError, Validator
from .compat import MAC, text_type, UserDict, WIN
logger = logging.getLogger(__name__)
class ConfigError(Exception):
"""Base class for exceptions in this module."""
pass
class DefaultConfigValidationError(ConfigError):
"""Indicates the default config file did not validate correctly."""
pass
class Config(UserDict, object):
"""Config reader/writer class.
:param str app_name: The application's name.
:param str app_author: The application author/organization.
:param str filename: The config filename to look for (e.g. ``config``).
:param dict/str default: The default config values or absolute path to
config file.
:param bool validate: Whether or not to validate the config file.
:param bool write_default: Whether or not to write the default config
file to the user config directory if it doesn't
already exist.
:param tuple additional_dirs: Additional directories to check for a config
file.
"""
def __init__(self, app_name, app_author, filename, default=None,
validate=False, write_default=False, additional_dirs=()):
super(Config, self).__init__()
#: The :class:`ConfigObj` instance.
self.data = ConfigObj()
self.default = {}
self.default_file = self.default_config = None
self.config_filenames = []
self.app_name, self.app_author = app_name, app_author
self.filename = filename
self.write_default = write_default
self.validate = validate
self.additional_dirs = additional_dirs
if isinstance(default, dict):
self.default = default
self.update(default)
elif isinstance(default, text_type):
self.default_file = default
elif default is not None:
raise TypeError(
'"default" must be a dict or {}, not {}'.format(
text_type.__name__, type(default)))
if self.write_default and not self.default_file:
raise ValueError('Cannot use "write_default" without specifying '
'a default file.')
if self.validate and not self.default_file:
raise ValueError('Cannot use "validate" without specifying a '
'default file.')
def read_default_config(self):
"""Read the default config file.
:raises DefaultConfigValidationError: There was a validation error with
the *default* file.
"""
if self.validate:
self.default_config = ConfigObj(configspec=self.default_file,
list_values=False, _inspec=True,
encoding='utf8')
valid = self.default_config.validate(Validator(), copy=True,
preserve_errors=True)
if valid is not True:
for name, section in valid.items():
if section is True:
continue
for key, value in section.items():
if isinstance(value, ValidateError):
raise DefaultConfigValidationError(
'section [{}], key "{}": {}'.format(
name, key, value))
elif self.default_file:
self.default_config, _ = self.read_config_file(self.default_file)
self.update(self.default_config)
def read(self):
"""Read the default, additional, system, and user config files.
:raises DefaultConfigValidationError: There was a validation error with
the *default* file.
"""
if self.default_file:
self.read_default_config()
return self.read_config_files(self.all_config_files())
def user_config_file(self):
"""Get the absolute path to the user config file."""
return os.path.join(
get_user_config_dir(self.app_name, self.app_author),
self.filename)
def system_config_files(self):
"""Get a list of absolute paths to the system config files."""
return [os.path.join(f, self.filename) for f in get_system_config_dirs(
self.app_name, self.app_author)]
def additional_files(self):
"""Get a list of absolute paths to the additional config files."""
return [os.path.join(f, self.filename) for f in self.additional_dirs]
def all_config_files(self):
"""Get a list of absolute paths to all the config files."""
return (self.additional_files() + self.system_config_files() +
[self.user_config_file()])
def write_default_config(self, overwrite=False):
"""Write the default config to the user's config file.
:param bool overwrite: Write over an existing config if it exists.
"""
destination = self.user_config_file()
if not overwrite and os.path.exists(destination):
return
with io.open(destination, mode='wb') as f:
self.default_config.write(f)
def write(self, outfile=None, section=None):
"""Write the current config to a file (defaults to user config).
:param str outfile: The path to the file to write to.
:param None/str section: The config section to write, or :data:`None`
to write the entire config.
"""
with io.open(outfile or self.user_config_file(), 'wb') as f:
self.data.write(outfile=f, section=section)
def read_config_file(self, f):
"""Read a config file *f*.
:param str f: The path to a file to read.
"""
configspec = self.default_file if self.validate else None
try:
config = ConfigObj(infile=f, configspec=configspec,
interpolation=False, encoding='utf8')
except ConfigObjError as e:
logger.warning(
'Unable to parse line {} of config file {}'.format(
e.line_number, f))
config = e.config
valid = True
if self.validate:
valid = config.validate(Validator(), preserve_errors=True,
copy=True)
if bool(config):
self.config_filenames.append(config.filename)
return config, valid
def read_config_files(self, files):
"""Read a list of config files.
:param iterable files: An iterable (e.g. list) of files to read.
"""
errors = {}
for _file in files:
config, valid = self.read_config_file(_file)
self.update(config)
if valid is not True:
errors[_file] = valid
return errors or True
def get_user_config_dir(app_name, app_author, roaming=True, force_xdg=True):
"""Returns the config folder for the application. The default behavior
is to return whatever is most appropriate for the operating system.
For an example application called ``"My App"`` by ``"Acme"``,
something like the following folders could be returned:
macOS (non-XDG):
``~/Library/Application Support/My App``
Mac OS X (XDG):
``~/.config/my-app``
Unix:
``~/.config/my-app``
Windows 7 (roaming):
``C:\\Users\\<user>\\AppData\\Roaming\\Acme\\My App``
Windows 7 (not roaming):
``C:\\Users\\<user>\\AppData\\Local\\Acme\\My App``
:param app_name: the application name. This should be properly capitalized
and can contain whitespace.
:param app_author: The app author's name (or company). This should be
properly capitalized and can contain whitespace.
:param roaming: controls if the folder should be roaming or not on Windows.
Has no effect on non-Windows systems.
:param force_xdg: if this is set to `True`, then on macOS the XDG Base
Directory Specification will be followed. Has no effect
on non-macOS systems.
"""
if WIN:
key = 'APPDATA' if roaming else 'LOCALAPPDATA'
folder = os.path.expanduser(os.environ.get(key, '~'))
return os.path.join(folder, app_author, app_name)
if MAC and not force_xdg:
return os.path.join(os.path.expanduser(
'~/Library/Application Support'), app_name)
return os.path.join(
os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config')),
_pathify(app_name))
def get_system_config_dirs(app_name, app_author, force_xdg=True):
r"""Returns a list of system-wide config folders for the application.
For an example application called ``"My App"`` by ``"Acme"``,
something like the following folders could be returned:
macOS (non-XDG):
``['/Library/Application Support/My App']``
Mac OS X (XDG):
``['/etc/xdg/my-app']``
Unix:
``['/etc/xdg/my-app']``
Windows 7:
``['C:\ProgramData\Acme\My App']``
:param app_name: the application name. This should be properly capitalized
and can contain whitespace.
:param app_author: The app author's name (or company). This should be
properly capitalized and can contain whitespace.
:param force_xdg: if this is set to `True`, then on macOS the XDG Base
Directory Specification will be followed. Has no effect
on non-macOS systems.
"""
if WIN:
folder = os.environ.get('PROGRAMDATA')
return [os.path.join(folder, app_author, app_name)]
if MAC and not force_xdg:
return [os.path.join('/Library/Application Support', app_name)]
dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg')
paths = [os.path.expanduser(x) for x in dirs.split(os.pathsep)]
return [os.path.join(d, _pathify(app_name)) for d in paths]
def _pathify(s):
"""Convert spaces to hyphens and lowercase a string."""
return '-'.join(s.split()).lower()

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""CLI Helper's tabular output module makes it easy to format your data using
various formatting libraries.
When formatting data, you'll primarily use the
:func:`~cli_helpers.tabular_output.format_output` function and
:class:`~cli_helpers.tabular_output.TabularOutputFormatter` class.
"""
from .output_formatter import format_output, TabularOutputFormatter
__all__ = ['format_output', 'TabularOutputFormatter']

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""A delimited data output adapter (e.g. CSV, TSV)."""
from __future__ import unicode_literals
import contextlib
from cli_helpers.compat import csv, StringIO
from cli_helpers.utils import filter_dict_by_key
from .preprocessors import bytes_to_string, override_missing_value
supported_formats = ('csv', 'csv-tab')
preprocessors = (override_missing_value, bytes_to_string)
class linewriter(object):
def __init__(self):
self.reset()
def reset(self):
self.line = None
def write(self, d):
self.line = d
def adapter(data, headers, table_format='csv', **kwargs):
"""Wrap the formatting inside a function for TabularOutputFormatter."""
keys = ('dialect', 'delimiter', 'doublequote', 'escapechar',
'quotechar', 'quoting', 'skipinitialspace', 'strict')
if table_format == 'csv':
delimiter = ','
elif table_format == 'csv-tab':
delimiter = '\t'
else:
raise ValueError('Invalid table_format specified.')
ckwargs = {'delimiter': delimiter, 'lineterminator': ''}
ckwargs.update(filter_dict_by_key(kwargs, keys))
l = linewriter()
writer = csv.writer(l, **ckwargs)
writer.writerow(headers)
yield l.line
for row in data:
l.reset()
writer.writerow(row)
yield l.line

View file

@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""A generic tabular data output formatter interface."""
from __future__ import unicode_literals
from collections import namedtuple
from cli_helpers.compat import (text_type, binary_type, int_types, float_types,
zip_longest)
from cli_helpers.utils import unique_items
from . import (delimited_output_adapter, vertical_table_adapter,
tabulate_adapter, terminaltables_adapter, tsv_output_adapter)
from decimal import Decimal
import itertools
MISSING_VALUE = '<null>'
MAX_FIELD_WIDTH = 500
TYPES = {
type(None): 0,
bool: 1,
int: 2,
float: 3,
Decimal: 3,
binary_type: 4,
text_type: 5
}
OutputFormatHandler = namedtuple(
'OutputFormatHandler',
'format_name preprocessors formatter formatter_args')
class TabularOutputFormatter(object):
"""An interface to various tabular data formatting libraries.
The formatting libraries supported include:
- `tabulate <https://bitbucket.org/astanin/python-tabulate>`_
- `terminaltables <https://robpol86.github.io/terminaltables/>`_
- a CLI Helper vertical table layout
- delimited formats (CSV and TSV)
:param str format_name: An optional, default format name.
Usage::
>>> from cli_helpers.tabular_output import TabularOutputFormatter
>>> formatter = TabularOutputFormatter(format_name='simple')
>>> data = ((1, 87), (2, 80), (3, 79))
>>> headers = ('day', 'temperature')
>>> print(formatter.format_output(data, headers))
day temperature
----- -------------
1 87
2 80
3 79
You can use any :term:`iterable` for the data or headers::
>>> data = enumerate(('87', '80', '79'), 1)
>>> print(formatter.format_output(data, headers))
day temperature
----- -------------
1 87
2 80
3 79
"""
_output_formats = {}
def __init__(self, format_name=None):
"""Set the default *format_name*."""
self._format_name = None
if format_name:
self.format_name = format_name
@property
def format_name(self):
"""The current format name.
This value must be in :data:`supported_formats`.
"""
return self._format_name
@format_name.setter
def format_name(self, format_name):
"""Set the default format name.
:param str format_name: The display format name.
:raises ValueError: if the format is not recognized.
"""
if format_name in self.supported_formats:
self._format_name = format_name
else:
raise ValueError('unrecognized format_name "{}"'.format(
format_name))
@property
def supported_formats(self):
"""The names of the supported output formats in a :class:`tuple`."""
return tuple(self._output_formats.keys())
@classmethod
def register_new_formatter(cls, format_name, handler, preprocessors=(),
kwargs=None):
"""Register a new output formatter.
:param str format_name: The name of the format.
:param callable handler: The function that formats the data.
:param tuple preprocessors: The preprocessors to call before
formatting.
:param dict kwargs: Keys/values for keyword argument defaults.
"""
cls._output_formats[format_name] = OutputFormatHandler(
format_name, preprocessors, handler, kwargs or {})
def format_output(self, data, headers, format_name=None,
preprocessors=(), column_types=None, **kwargs):
r"""Format the headers and data using a specific formatter.
*format_name* must be a supported formatter (see
:attr:`supported_formats`).
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str format_name: The display format to use (optional, if the
:class:`TabularOutputFormatter` object has a default format set).
:param tuple preprocessors: Additional preprocessors to call before
any formatter preprocessors.
:param \*\*kwargs: Optional arguments for the formatter.
:return: The formatted data.
:rtype: str
:raises ValueError: If the *format_name* is not recognized.
"""
format_name = format_name or self._format_name
if format_name not in self.supported_formats:
raise ValueError('unrecognized format "{}"'.format(format_name))
(_, _preprocessors, formatter,
fkwargs) = self._output_formats[format_name]
fkwargs.update(kwargs)
if column_types is None:
data = list(data)
column_types = self._get_column_types(data)
for f in unique_items(preprocessors + _preprocessors):
data, headers = f(data, headers, column_types=column_types,
**fkwargs)
return formatter(list(data), headers, column_types=column_types, **fkwargs)
def _get_column_types(self, data):
"""Get a list of the data types for each column in *data*."""
columns = list(zip_longest(*data))
return [self._get_column_type(column) for column in columns]
def _get_column_type(self, column):
"""Get the most generic data type for iterable *column*."""
type_values = [TYPES[self._get_type(v)] for v in column]
inverse_types = {v: k for k, v in TYPES.items()}
return inverse_types[max(type_values)]
def _get_type(self, value):
"""Get the data type for *value*."""
if value is None:
return type(None)
elif type(value) in int_types:
return int
elif type(value) in float_types:
return float
elif isinstance(value, binary_type):
return binary_type
else:
return text_type
def format_output(data, headers, format_name, **kwargs):
r"""Format output using *format_name*.
This is a wrapper around the :class:`TabularOutputFormatter` class.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str format_name: The display format to use.
:param \*\*kwargs: Optional arguments for the formatter.
:return: The formatted data.
:rtype: str
"""
formatter = TabularOutputFormatter(format_name=format_name)
return formatter.format_output(data, headers, **kwargs)
for vertical_format in vertical_table_adapter.supported_formats:
TabularOutputFormatter.register_new_formatter(
vertical_format, vertical_table_adapter.adapter,
vertical_table_adapter.preprocessors,
{'table_format': vertical_format, 'missing_value': MISSING_VALUE, 'max_field_width': None})
for delimited_format in delimited_output_adapter.supported_formats:
TabularOutputFormatter.register_new_formatter(
delimited_format, delimited_output_adapter.adapter,
delimited_output_adapter.preprocessors,
{'table_format': delimited_format, 'missing_value': '', 'max_field_width': None})
for tabulate_format in tabulate_adapter.supported_formats:
TabularOutputFormatter.register_new_formatter(
tabulate_format, tabulate_adapter.adapter,
tabulate_adapter.preprocessors +
(tabulate_adapter.style_output_table(tabulate_format),),
{'table_format': tabulate_format, 'missing_value': MISSING_VALUE, 'max_field_width': MAX_FIELD_WIDTH})
for terminaltables_format in terminaltables_adapter.supported_formats:
TabularOutputFormatter.register_new_formatter(
terminaltables_format, terminaltables_adapter.adapter,
terminaltables_adapter.preprocessors +
(terminaltables_adapter.style_output_table(terminaltables_format),),
{'table_format': terminaltables_format, 'missing_value': MISSING_VALUE, 'max_field_width': MAX_FIELD_WIDTH})
for tsv_format in tsv_output_adapter.supported_formats:
TabularOutputFormatter.register_new_formatter(
tsv_format, tsv_output_adapter.adapter,
tsv_output_adapter.preprocessors,
{'table_format': tsv_format, 'missing_value': '', 'max_field_width': None})

View file

@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""These preprocessor functions are used to process data prior to output."""
import string
from cli_helpers import utils
from cli_helpers.compat import text_type, int_types, float_types, HAS_PYGMENTS
def truncate_string(data, headers, max_field_width=None, skip_multiline_string=True, **_):
"""Truncate very long strings. Only needed for tabular
representation, because trying to tabulate very long data
is problematic in terms of performance, and does not make any
sense visually.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param int max_field_width: Width to truncate field for display
:return: The processed data and headers.
:rtype: tuple
"""
return (([utils.truncate_string(v, max_field_width, skip_multiline_string) for v in row] for row in data),
[utils.truncate_string(h, max_field_width, skip_multiline_string) for h in headers])
def convert_to_string(data, headers, **_):
"""Convert all *data* and *headers* to strings.
Binary data that cannot be decoded is converted to a hexadecimal
representation via :func:`binascii.hexlify`.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:return: The processed data and headers.
:rtype: tuple
"""
return (([utils.to_string(v) for v in row] for row in data),
[utils.to_string(h) for h in headers])
def override_missing_value(data, headers, style=None,
missing_value_token="Token.Output.Null",
missing_value='', **_):
"""Override missing values in the *data* with *missing_value*.
A missing value is any value that is :data:`None`.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param style: Style for missing_value.
:param missing_value_token: The Pygments token used for missing data.
:param missing_value: The default value to use for missing data.
:return: The processed data and headers.
:rtype: tuple
"""
def fields():
for row in data:
processed = []
for field in row:
if field is None and style and HAS_PYGMENTS:
styled = utils.style_field(missing_value_token, missing_value, style)
processed.append(styled)
elif field is None:
processed.append(missing_value)
else:
processed.append(field)
yield processed
return (fields(), headers)
def override_tab_value(data, headers, new_value=' ', **_):
"""Override tab values in the *data* with *new_value*.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param new_value: The new value to use for tab.
:return: The processed data and headers.
:rtype: tuple
"""
return (([v.replace('\t', new_value) if isinstance(v, text_type) else v
for v in row] for row in data),
headers)
def escape_newlines(data, headers, **_):
"""Escape newline characters (\n -> \\n, \r -> \\r)
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:return: The processed data and headers.
:rtype: tuple
"""
return (
(
[
v.replace("\r", r"\r").replace("\n", r"\n")
if isinstance(v, text_type)
else v
for v in row
]
for row in data
),
headers,
)
def bytes_to_string(data, headers, **_):
"""Convert all *data* and *headers* bytes to strings.
Binary data that cannot be decoded is converted to a hexadecimal
representation via :func:`binascii.hexlify`.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:return: The processed data and headers.
:rtype: tuple
"""
return (([utils.bytes_to_string(v) for v in row] for row in data),
[utils.bytes_to_string(h) for h in headers])
def align_decimals(data, headers, column_types=(), **_):
"""Align numbers in *data* on their decimal points.
Whitespace padding is added before a number so that all numbers in a
column are aligned.
Outputting data before aligning the decimals::
1
2.1
10.59
Outputting data after aligning the decimals::
1
2.1
10.59
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param iterable column_types: The columns' type objects (e.g. int or float).
:return: The processed data and headers.
:rtype: tuple
"""
pointpos = len(headers) * [0]
data = list(data)
for row in data:
for i, v in enumerate(row):
if column_types[i] is float and type(v) in float_types:
v = text_type(v)
pointpos[i] = max(utils.intlen(v), pointpos[i])
def results(data):
for row in data:
result = []
for i, v in enumerate(row):
if column_types[i] is float and type(v) in float_types:
v = text_type(v)
result.append((pointpos[i] - utils.intlen(v)) * " " + v)
else:
result.append(v)
yield result
return results(data), headers
def quote_whitespaces(data, headers, quotestyle="'", **_):
"""Quote leading/trailing whitespace in *data*.
When outputing data with leading or trailing whitespace, it can be useful
to put quotation marks around the value so the whitespace is more
apparent. If one value in a column needs quoted, then all values in that
column are quoted to keep things consistent.
.. NOTE::
:data:`string.whitespace` is used to determine which characters are
whitespace.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str quotestyle: The quotation mark to use (defaults to ``'``).
:return: The processed data and headers.
:rtype: tuple
"""
whitespace = tuple(string.whitespace)
quote = len(headers) * [False]
data = list(data)
for row in data:
for i, v in enumerate(row):
v = text_type(v)
if v.startswith(whitespace) or v.endswith(whitespace):
quote[i] = True
def results(data):
for row in data:
result = []
for i, v in enumerate(row):
quotation = quotestyle if quote[i] else ''
result.append('{quotestyle}{value}{quotestyle}'.format(
quotestyle=quotation, value=v))
yield result
return results(data), headers
def style_output(data, headers, style=None,
header_token='Token.Output.Header',
odd_row_token='Token.Output.OddRow',
even_row_token='Token.Output.EvenRow', **_):
"""Style the *data* and *headers* (e.g. bold, italic, and colors)
.. NOTE::
This requires the `Pygments <http://pygments.org/>`_ library to
be installed. You can install it with CLI Helpers as an extra::
$ pip install cli_helpers[styles]
Example usage::
from cli_helpers.tabular_output.preprocessors import style_output
from pygments.style import Style
from pygments.token import Token
class YourStyle(Style):
default_style = ""
styles = {
Token.Output.Header: 'bold ansibrightred',
Token.Output.OddRow: 'bg:#eee #111',
Token.Output.EvenRow: '#0f0'
}
headers = ('First Name', 'Last Name')
data = [['Fred', 'Roberts'], ['George', 'Smith']]
data, headers = style_output(data, headers, style=YourStyle)
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str/pygments.style.Style style: A Pygments style. You can `create
your own styles <https://pygments.org/docs/styles#creating-own-styles>`_.
:param str header_token: The token type to be used for the headers.
:param str odd_row_token: The token type to be used for odd rows.
:param str even_row_token: The token type to be used for even rows.
:return: The styled data and headers.
:rtype: tuple
"""
from cli_helpers.utils import filter_style_table
relevant_styles = filter_style_table(style, header_token, odd_row_token, even_row_token)
if style and HAS_PYGMENTS:
if relevant_styles.get(header_token):
headers = [utils.style_field(header_token, header, style) for header in headers]
if relevant_styles.get(odd_row_token) or relevant_styles.get(even_row_token):
data = ([utils.style_field(odd_row_token if i % 2 else even_row_token, f, style)
for f in r] for i, r in enumerate(data, 1))
return iter(data), headers
def format_numbers(data, headers, column_types=(), integer_format=None,
float_format=None, **_):
"""Format numbers according to a format specification.
This uses Python's format specification to format numbers of the following
types: :class:`int`, :class:`py2:long` (Python 2), :class:`float`, and
:class:`~decimal.Decimal`. See the :ref:`python:formatspec` for more
information about the format strings.
.. NOTE::
A column is only formatted if all of its values are the same type
(except for :data:`None`).
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param iterable column_types: The columns' type objects (e.g. int or float).
:param str integer_format: The format string to use for integer columns.
:param str float_format: The format string to use for float columns.
:return: The processed data and headers.
:rtype: tuple
"""
if (integer_format is None and float_format is None) or not column_types:
return iter(data), headers
def _format_number(field, column_type):
if integer_format and column_type is int and type(field) in int_types:
return format(field, integer_format)
elif float_format and column_type is float and type(field) in float_types:
return format(field, float_format)
return field
data = ([_format_number(v, column_types[i]) for i, v in enumerate(row)] for row in data)
return data, headers

View file

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""Format adapter for the tabulate module."""
from __future__ import unicode_literals
from cli_helpers.utils import filter_dict_by_key
from cli_helpers.compat import (Terminal256Formatter, StringIO)
from .preprocessors import (convert_to_string, truncate_string, override_missing_value,
style_output, HAS_PYGMENTS)
import tabulate
supported_markup_formats = ('mediawiki', 'html', 'latex', 'latex_booktabs',
'textile', 'moinmoin', 'jira')
supported_table_formats = ('plain', 'simple', 'grid', 'fancy_grid', 'pipe',
'orgtbl', 'psql', 'rst')
supported_formats = supported_markup_formats + supported_table_formats
preprocessors = (override_missing_value, convert_to_string, truncate_string, style_output)
def style_output_table(format_name=""):
def style_output(data, headers, style=None,
table_separator_token='Token.Output.TableSeparator', **_):
"""Style the *table* a(e.g. bold, italic, and colors)
.. NOTE::
This requires the `Pygments <http://pygments.org/>`_ library to
be installed. You can install it with CLI Helpers as an extra::
$ pip install cli_helpers[styles]
Example usage::
from cli_helpers.tabular_output import tabulate_adapter
from pygments.style import Style
from pygments.token import Token
class YourStyle(Style):
default_style = ""
styles = {
Token.Output.TableSeparator: '#ansigray'
}
headers = ('First Name', 'Last Name')
data = [['Fred', 'Roberts'], ['George', 'Smith']]
style_output_table = tabulate_adapter.style_output_table('psql')
style_output_table(data, headers, style=CliStyle)
data, headers = style_output(data, headers, style=YourStyle)
output = tabulate_adapter.adapter(data, headers, style=YourStyle)
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str/pygments.style.Style style: A Pygments style. You can `create
your own styles <https://pygments.org/docs/styles#creating-own-styles>`_.
:param str table_separator_token: The token type to be used for the table separator.
:return: data and headers.
:rtype: tuple
"""
if style and HAS_PYGMENTS and format_name in supported_table_formats:
formatter = Terminal256Formatter(style=style)
def style_field(token, field):
"""Get the styled text for a *field* using *token* type."""
s = StringIO()
formatter.format(((token, field),), s)
return s.getvalue()
def addColorInElt(elt):
if not elt:
return elt
if elt.__class__ == tabulate.Line:
return tabulate.Line(*(style_field(table_separator_token, val) for val in elt))
if elt.__class__ == tabulate.DataRow:
return tabulate.DataRow(*(style_field(table_separator_token, val) for val in elt))
return elt
srcfmt = tabulate._table_formats[format_name]
newfmt = tabulate.TableFormat(
*(addColorInElt(val) for val in srcfmt))
tabulate._table_formats[format_name] = newfmt
return iter(data), headers
return style_output
def adapter(data, headers, table_format=None, preserve_whitespace=False,
**kwargs):
"""Wrap tabulate inside a function for TabularOutputFormatter."""
keys = ('floatfmt', 'numalign', 'stralign', 'showindex', 'disable_numparse')
tkwargs = {'tablefmt': table_format}
tkwargs.update(filter_dict_by_key(kwargs, keys))
if table_format in supported_markup_formats:
tkwargs.update(numalign=None, stralign=None)
tabulate.PRESERVE_WHITESPACE = preserve_whitespace
return iter(tabulate.tabulate(data, headers, **tkwargs).split('\n'))

View file

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""Format adapter for the terminaltables module."""
from __future__ import unicode_literals
import terminaltables
import itertools
from cli_helpers.utils import filter_dict_by_key
from cli_helpers.compat import (Terminal256Formatter, StringIO)
from .preprocessors import (convert_to_string, truncate_string, override_missing_value,
style_output, HAS_PYGMENTS,
override_tab_value, escape_newlines)
supported_formats = ('ascii', 'double', 'github')
preprocessors = (
override_missing_value, convert_to_string, override_tab_value,
truncate_string, style_output, escape_newlines
)
table_format_handler = {
'ascii': terminaltables.AsciiTable,
'double': terminaltables.DoubleTable,
'github': terminaltables.GithubFlavoredMarkdownTable,
}
def style_output_table(format_name=""):
def style_output(data, headers, style=None,
table_separator_token='Token.Output.TableSeparator', **_):
"""Style the *table* (e.g. bold, italic, and colors)
.. NOTE::
This requires the `Pygments <http://pygments.org/>`_ library to
be installed. You can install it with CLI Helpers as an extra::
$ pip install cli_helpers[styles]
Example usage::
from cli_helpers.tabular_output import terminaltables_adapter
from pygments.style import Style
from pygments.token import Token
class YourStyle(Style):
default_style = ""
styles = {
Token.Output.TableSeparator: '#ansigray'
}
headers = ('First Name', 'Last Name')
data = [['Fred', 'Roberts'], ['George', 'Smith']]
style_output_table = terminaltables_adapter.style_output_table('psql')
style_output_table(data, headers, style=CliStyle)
output = terminaltables_adapter.adapter(data, headers, style=YourStyle)
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str/pygments.style.Style style: A Pygments style. You can `create
your own styles <https://pygments.org/docs/styles#creating-own-styles>`_.
:param str table_separator_token: The token type to be used for the table separator.
:return: data and headers.
:rtype: tuple
"""
if style and HAS_PYGMENTS and format_name in supported_formats:
formatter = Terminal256Formatter(style=style)
def style_field(token, field):
"""Get the styled text for a *field* using *token* type."""
s = StringIO()
formatter.format(((token, field),), s)
return s.getvalue()
clss = table_format_handler[format_name]
for char in [char for char in terminaltables.base_table.BaseTable.__dict__ if char.startswith("CHAR_")]:
setattr(clss, char, style_field(
table_separator_token, getattr(clss, char)))
return iter(data), headers
return style_output
def adapter(data, headers, table_format=None, **kwargs):
"""Wrap terminaltables inside a function for TabularOutputFormatter."""
keys = ('title', )
table = table_format_handler[table_format]
t = table([headers] + list(data), **filter_dict_by_key(kwargs, keys))
dimensions = terminaltables.width_and_alignment.max_dimensions(
t.table_data,
t.padding_left,
t.padding_right)[:3]
for r in t.gen_table(*dimensions):
yield u''.join(r)

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""A tsv data output adapter"""
from __future__ import unicode_literals
from .preprocessors import bytes_to_string, override_missing_value, convert_to_string
from itertools import chain
from cli_helpers.utils import replace
supported_formats = ('tsv',)
preprocessors = (override_missing_value, bytes_to_string, convert_to_string)
def adapter(data, headers, **kwargs):
"""Wrap the formatting inside a function for TabularOutputFormatter."""
for row in chain((headers,), data):
yield "\t".join((replace(r, (('\n', r'\n'), ('\t', r'\t'))) for r in row))

View file

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Format data into a vertical table layout."""
from __future__ import unicode_literals
from cli_helpers.utils import filter_dict_by_key
from .preprocessors import (convert_to_string, override_missing_value,
style_output)
supported_formats = ('vertical', )
preprocessors = (override_missing_value, convert_to_string, style_output)
def _get_separator(num, sep_title, sep_character, sep_length):
"""Get a row separator for row *num*."""
left_divider_length = right_divider_length = sep_length
if isinstance(sep_length, tuple):
left_divider_length, right_divider_length = sep_length
left_divider = sep_character * left_divider_length
right_divider = sep_character * right_divider_length
title = sep_title.format(n=num + 1)
return "{left_divider}[ {title} ]{right_divider}\n".format(
left_divider=left_divider, right_divider=right_divider, title=title)
def _format_row(headers, row):
"""Format a row."""
formatted_row = [' | '.join(field) for field in zip(headers, row)]
return '\n'.join(formatted_row)
def vertical_table(data, headers, sep_title='{n}. row', sep_character='*',
sep_length=27):
"""Format *data* and *headers* as an vertical table.
The values in *data* and *headers* must be strings.
:param iterable data: An :term:`iterable` (e.g. list) of rows.
:param iterable headers: The column headers.
:param str sep_title: The title given to each row separator. Defaults to
``'{n}. row'``. Any instance of ``'{n}'`` is
replaced by the record number.
:param str sep_character: The character used to separate rows. Defaults to
``'*'``.
:param int/tuple sep_length: The number of separator characters that should
appear on each side of the *sep_title*. Use
a tuple to specify the left and right values
separately.
:return: The formatted data.
:rtype: str
"""
header_len = max([len(x) for x in headers])
padded_headers = [x.ljust(header_len) for x in headers]
formatted_rows = [_format_row(padded_headers, row) for row in data]
output = []
for i, result in enumerate(formatted_rows):
yield _get_separator(i, sep_title, sep_character, sep_length) + result
def adapter(data, headers, **kwargs):
"""Wrap vertical table in a function for TabularOutputFormatter."""
keys = ('sep_title', 'sep_character', 'sep_length')
return vertical_table(data, headers, **filter_dict_by_key(kwargs, keys))

106
cli_helpers/utils.py Normal file
View file

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""Various utility functions and helpers."""
import binascii
import re
from functools import lru_cache
from typing import Dict
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pygments.style import StyleMeta
from cli_helpers.compat import binary_type, text_type, Terminal256Formatter, StringIO
def bytes_to_string(b):
"""Convert bytes *b* to a string.
Hexlify bytes that can't be decoded.
"""
if isinstance(b, binary_type):
try:
return b.decode('utf8')
except UnicodeDecodeError:
return '0x' + binascii.hexlify(b).decode('ascii')
return b
def to_string(value):
"""Convert *value* to a string."""
if isinstance(value, binary_type):
return bytes_to_string(value)
else:
return text_type(value)
def truncate_string(value, max_width=None, skip_multiline_string=True):
"""Truncate string values."""
if skip_multiline_string and isinstance(value, text_type) and '\n' in value:
return value
elif isinstance(value, text_type) and max_width is not None and len(value) > max_width:
return value[:max_width-3] + "..."
return value
def intlen(n):
"""Find the length of the integer part of a number *n*."""
pos = n.find('.')
return len(n) if pos < 0 else pos
def filter_dict_by_key(d, keys):
"""Filter the dict *d* to remove keys not in *keys*."""
return {k: v for k, v in d.items() if k in keys}
def unique_items(seq):
"""Return the unique items from iterable *seq* (in order)."""
seen = set()
return [x for x in seq if not (x in seen or seen.add(x))]
_ansi_re = re.compile('\033\\[((?:\\d|;)*)([a-zA-Z])')
def strip_ansi(value):
"""Strip the ANSI escape sequences from a string."""
return _ansi_re.sub('', value)
def replace(s, replace):
"""Replace multiple values in a string"""
for r in replace:
s = s.replace(*r)
return s
@lru_cache()
def _get_formatter(style) -> Terminal256Formatter:
return Terminal256Formatter(style=style)
def style_field(token, field, style):
"""Get the styled text for a *field* using *token* type."""
formatter = _get_formatter(style)
s = StringIO()
formatter.format(((token, field),), s)
return s.getvalue()
def filter_style_table(style: "StyleMeta", *relevant_styles: str) -> Dict:
"""
get a dictionary of styles for given tokens. Typical usage:
filter_style_table(style, 'Token.Output.EvenRow', 'Token.Output.OddRow') == {
'Token.Output.EvenRow': "",
'Token.Output.OddRow': "",
}
"""
_styles_iter = ((str(key), val) for key, val in getattr(style, 'styles', {}).items())
_relevant_styles_iter = filter(
lambda tpl: tpl[0] in relevant_styles,
_styles_iter
)
return {key: val for key, val in _relevant_styles_iter}

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = CLIHelpers
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

23
docs/source/api.rst Normal file
View file

@ -0,0 +1,23 @@
API
===
.. automodule:: cli_helpers
Tabular Output
--------------
.. automodule:: cli_helpers.tabular_output
:members:
:imported-members:
Preprocessors
+++++++++++++
.. automodule:: cli_helpers.tabular_output.preprocessors
:members:
Config
------
.. automodule:: cli_helpers.config
:members:

1
docs/source/authors.rst Normal file
View file

@ -0,0 +1 @@
.. include:: ../../AUTHORS

View file

@ -0,0 +1 @@
.. include:: ../../CHANGELOG

200
docs/source/conf.py Normal file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# CLI Helpers documentation build configuration file, created by
# sphinx-quickstart on Mon Apr 17 20:26:02 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import ast
from collections import OrderedDict
# import os
import re
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode'
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
html_sidebars = {
'**': [
'about.html',
'navigation.html',
'relations.html',
'searchbox.html',
'donate.html',
]
}
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'CLI Helpers'
author = 'dbcli'
description = 'Python helpers for common CLI tasks'
copyright = '2017, dbcli'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
_version_re = re.compile(r'__version__\s+=\s+(.*)')
with open('../../cli_helpers/__init__.py', 'rb') as f:
version = str(ast.literal_eval(_version_re.search(
f.read().decode('utf-8')).group(1)))
# The full version, including alpha/beta/rc tags.
release = version
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
nav_links = OrderedDict((
('CLI Helpers at GitHub', 'https://github.com/dbcli/cli_helpers'),
('CLI Helpers at PyPI', 'https://pypi.org/project/cli_helpers'),
('Issue Tracker', 'https://github.com/dbcli/cli_helpers/issues')
))
html_theme_options = {
'description': description,
'github_user': 'dbcli',
'github_repo': 'cli_helpers',
'github_banner': False,
'github_button': False,
'github_type': 'watch',
'github_count': False,
'extra_nav_links': nav_links
}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'CLIHelpersdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'CLIHelpers.tex', 'CLI Helpers Documentation',
'dbcli', 'manual'),
]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'clihelpers', 'CLI Helpers Documentation',
[author], 1)
]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'CLIHelpers', 'CLI Helpers Documentation',
author, 'CLIHelpers', description,
'Miscellaneous'),
]
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'py2': ('https://docs.python.org/2', None),
'pymysql': ('https://pymysql.readthedocs.io/en/latest/', None),
'numpy': ('https://docs.scipy.org/doc/numpy', None),
'configobj': ('https://configobj.readthedocs.io/en/latest', None)
}

View file

@ -0,0 +1 @@
.. include:: ../../CONTRIBUTING.rst

30
docs/source/index.rst Normal file
View file

@ -0,0 +1,30 @@
Welcome to CLI Helpers
======================
.. include:: ../../README.rst
:start-after: start-body
:end-before: end-body
Installation
------------
You can get the library directly from `PyPI <https://pypi.org/>`_::
$ pip install cli_helpers
User Guide
----------
.. toctree::
:maxdepth: 2
quickstart
contributing
changelog
authors
license
API
---
.. toctree::
:maxdepth: 2
api

13
docs/source/license.rst Normal file
View file

@ -0,0 +1,13 @@
License
=======
CLI Helpers is licensed under the BSD 3-clause license. This basically means
you can do what you'd like with the source code as long as you include a copy
of the license, don't modify the conditions, and keep the disclaimer around.
Plus, you can't use the authors' names to promote your software without their
written consent.
License Text
++++++++++++
.. include:: ../../LICENSE

153
docs/source/quickstart.rst Normal file
View file

@ -0,0 +1,153 @@
Quickstart
==========
Displaying Tabular Data
-----------------------
The Basics
++++++++++
CLI Helpers provides a simple way to display your tabular data (columns/rows) in a visually-appealing manner::
>>> from cli_helpers import tabular_output
>>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]]
>>> headers = ['id', 'city', 'visited']
>>> print(tabular_output.format_output(data, headers, format_name='simple'))
id city visited
---- --------- ---------
1 Asgard True
2 Camelot False
3 El Dorado True
Let's take a look at what we did there.
1. We imported the :mod:`~cli_helpers.tabular_output` module. This module gives us access to the :func:`~cli_helpers.tabular_output.format_output` function.
2. Next we generate some data. Plus, we need a list of headers to give our data some context.
3. We format the output using the display format ``simple``. That's a nice looking table!
Display Formats
+++++++++++++++
To display your data, :mod:`~cli_helpers.tabular_output` uses
`tabulate <https://bitbucket.org/astanin/python-tabulate>`_,
`terminaltables <https://robpol86.github.io/terminaltables/>`_, :mod:`csv`,
and its own vertical table layout.
The best way to see the various display formats is to use the
:class:`~cli_helpers.tabular_output.TabularOutputFormatter` class. This is
what the :func:`~cli_helpers.tabular_output.format_output` function in our
first example uses behind the scenes.
Let's get a list of all the supported format names::
>>> from cli_helpers.tabular_output import TabularOutputFormatter
>>> formatter = TabularOutputFormatter()
>>> formatter.supported_formats
('vertical', 'csv', 'tsv', 'mediawiki', 'html', 'latex', 'latex_booktabs', 'textile', 'moinmoin', 'jira', 'plain', 'simple', 'grid', 'fancy_grid', 'pipe', 'orgtbl', 'psql', 'rst', 'ascii', 'double', 'github')
You can format your data in any of those supported formats. Let's take the
same data from our first example and put it in the ``fancy_grid`` format::
>>> data = [[1, 'Asgard', True], [2, 'Camelot', False], [3, 'El Dorado', True]]
>>> headers = ['id', 'city', 'visited']
>>> print(formatter.format_output(data, headers, format_name='fancy_grid'))
╒══════╤═══════════╤═══════════╕
│ id │ city │ visited │
╞══════╪═══════════╪═══════════╡
│ 1 │ Asgard │ True │
├──────┼───────────┼───────────┤
│ 2 │ Camelot │ False │
├──────┼───────────┼───────────┤
│ 3 │ El Dorado │ True │
╘══════╧═══════════╧═══════════╛
That was easy! How about CLI Helper's vertical table layout?
>>> print(formatter.format_output(data, headers, format_name='vertical'))
***************************[ 1. row ]***************************
id | 1
city | Asgard
visited | True
***************************[ 2. row ]***************************
id | 2
city | Camelot
visited | False
***************************[ 3. row ]***************************
id | 3
city | El Dorado
visited | True
Default Format
++++++++++++++
When you create a :class:`~cli_helpers.tabular_output.TabularOutputFormatter`
object, you can specify a default formatter so you don't have to pass the
format name each time you want to format your data::
>>> formatter = TabularOutputFormatter(format_name='plain')
>>> print(formatter.format_output(data, headers))
id city visited
1 Asgard True
2 Camelot False
3 El Dorado True
.. TIP::
You can get or set the default format whenever you'd like through
:data:`TabularOutputFormatter.format_name <cli_helpers.tabular_output.TabularOutputFormatter.format_name>`.
Passing Options to the Formatters
+++++++++++++++++++++++++++++++++
Many of the formatters have settings that can be tweaked by passing
an optional argument when you format your data. For example,
if we wanted to enable or disable number parsing on any of
`tabulate's <https://bitbucket.org/astanin/python-tabulate>`_
formats, we could::
>>> data = [[1, 1.5], [2, 19.605], [3, 100.0]]
>>> headers = ['id', 'rating']
>>> print(format_output(data, headers, format_name='simple', disable_numparse=True))
id rating
---- --------
1 1.5
2 19.605
3 100.0
>>> print(format_output(data, headers, format_name='simple', disable_numparse=False))
id rating
---- --------
1 1.5
2 19.605
3 100
Lists and tuples and bytearrays. Oh my!
+++++++++++++++++++++++++++++++++++++++
:mod:`~cli_helpers.tabular_output` supports any :term:`iterable`, not just
a :class:`list` or :class:`tuple`. You can use a :class:`range`,
:func:`enumerate`, a :class:`str`, or even a :class:`bytearray`! Here is a
far-fetched example to prove the point::
>>> step = 3
>>> data = [range(n, n + step) for n in range(0, 9, step)]
>>> headers = 'abc'
>>> print(format_output(data, headers, format_name='simple'))
a b c
--- --- ---
0 1 2
3 4 5
6 7 8
Real life examples include a PyMySQL
:class:`Cursor <pymysql:pymysql.cursors.Cursor>` with
database results or
NumPy :class:`ndarray <numpy:numpy.ndarray>` with data points.

135
release.py Normal file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env python
"""A script to publish a release of cli_helpers to PyPI."""
import io
from optparse import OptionParser
import re
import subprocess
import sys
import click
DEBUG = False
CONFIRM_STEPS = False
DRY_RUN = False
def skip_step():
"""
Asks for user's response whether to run a step. Default is yes.
:return: boolean
"""
global CONFIRM_STEPS
if CONFIRM_STEPS:
return not click.confirm("--- Run this step?", default=True)
return False
def run_step(*args):
"""
Prints out the command and asks if it should be run.
If yes (default), runs it.
:param args: list of strings (command and args)
"""
global DRY_RUN
cmd = args
print(" ".join(cmd))
if skip_step():
print("--- Skipping...")
elif DRY_RUN:
print("--- Pretending to run...")
else:
subprocess.check_output(cmd)
def version(version_file):
_version_re = re.compile(
r'__version__\s+=\s+(?P<quote>[\'"])(?P<version>.*)(?P=quote)'
)
with io.open(version_file, encoding="utf-8") as f:
ver = _version_re.search(f.read()).group("version")
return ver
def commit_for_release(version_file, ver):
run_step("git", "reset")
run_step("git", "add", version_file)
run_step("git", "commit", "--message", "Releasing version {}".format(ver))
def create_git_tag(tag_name):
run_step("git", "tag", tag_name)
def create_distribution_files():
run_step("python", "setup.py", "clean", "--all", "sdist", "bdist_wheel")
def upload_distribution_files():
run_step("twine", "upload", "dist/*")
def push_to_github():
run_step("git", "push", "origin", "master")
def push_tags_to_github():
run_step("git", "push", "--tags", "origin")
def checklist(questions):
for question in questions:
if not click.confirm("--- {}".format(question), default=False):
sys.exit(1)
if __name__ == "__main__":
if DEBUG:
subprocess.check_output = lambda x: x
checks = [
"Have you updated the AUTHORS file?",
"Have you updated the `Usage` section of the README?",
]
checklist(checks)
ver = version("cli_helpers/__init__.py")
print("Releasing Version:", ver)
parser = OptionParser()
parser.add_option(
"-c",
"--confirm-steps",
action="store_true",
dest="confirm_steps",
default=False,
help=(
"Confirm every step. If the step is not " "confirmed, it will be skipped."
),
)
parser.add_option(
"-d",
"--dry-run",
action="store_true",
dest="dry_run",
default=False,
help="Print out, but not actually run any steps.",
)
popts, pargs = parser.parse_args()
CONFIRM_STEPS = popts.confirm_steps
DRY_RUN = popts.dry_run
if not click.confirm("Are you sure?", default=False):
sys.exit(1)
commit_for_release("cli_helpers/__init__.py", ver)
create_git_tag("v{}".format(ver))
create_distribution_files()
push_to_github()
push_tags_to_github()
upload_distribution_files()

10
requirements-dev.txt Normal file
View file

@ -0,0 +1,10 @@
autopep8==1.3.3
codecov==2.0.9
coverage==4.3.4
pep8radius
Pygments>=2.4.0
pytest==3.0.7
pytest-cov==2.4.0
Sphinx==1.5.5
tox==2.7.0
twine==1.12.1

13
setup.cfg Normal file
View file

@ -0,0 +1,13 @@
[coverage:run]
source = cli_helpers
omit = cli_helpers/packages/*.py
[check-manifest]
ignore =
appveyor.yml
.travis.yml
.github*
.travis*
[tool:pytest]
testpaths = tests

62
setup.py Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import ast
from io import open
import re
import sys
from setuptools import find_packages, setup
_version_re = re.compile(r'__version__\s+=\s+(.*)')
with open('cli_helpers/__init__.py', 'rb') as f:
version = str(ast.literal_eval(_version_re.search(
f.read().decode('utf-8')).group(1)))
def open_file(filename):
"""Open and read the file *filename*."""
with open(filename) as f:
return f.read()
readme = open_file('README.rst')
if sys.version_info[0] == 2:
py2_reqs = ['backports.csv >= 1.0.0']
else:
py2_reqs = []
setup(
name='cli_helpers',
author='dbcli',
author_email='thomas@roten.us',
version=version,
url='https://github.com/dbcli/cli_helpers',
packages=find_packages(exclude=['docs', 'tests', 'tests.tabular_output']),
include_package_data=True,
description='Helpers for building command-line apps',
long_description=readme,
long_description_content_type='text/x-rst',
install_requires=[
'configobj >= 5.0.5',
'tabulate[widechars] >= 0.8.2',
'terminaltables >= 3.0.0',
] + py2_reqs,
extras_require={
'styles': ['Pygments >= 1.6'],
},
classifiers=[
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: Unix',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Topic :: Software Development',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Terminals :: Terminal Emulators/X Terminals',
]
)

122
tasks.py Normal file
View file

@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""Common development tasks for setup.py to use."""
import re
import subprocess
import sys
from setuptools import Command
class BaseCommand(Command, object):
"""The base command for project tasks."""
user_options = []
default_cmd_options = ('verbose', 'quiet', 'dry_run')
def __init__(self, *args, **kwargs):
super(BaseCommand, self).__init__(*args, **kwargs)
self.verbose = False
def initialize_options(self):
"""Override the distutils abstract method."""
pass
def finalize_options(self):
"""Override the distutils abstract method."""
# Distutils uses incrementing integers for verbosity.
self.verbose = bool(self.verbose)
def call_and_exit(self, cmd, shell=True):
"""Run the *cmd* and exit with the proper exit code."""
sys.exit(subprocess.call(cmd, shell=shell))
def call_in_sequence(self, cmds, shell=True):
"""Run multiple commmands in a row, exiting if one fails."""
for cmd in cmds:
if subprocess.call(cmd, shell=shell) == 1:
sys.exit(1)
def apply_options(self, cmd, options=()):
"""Apply command-line options."""
for option in (self.default_cmd_options + options):
cmd = self.apply_option(cmd, option,
active=getattr(self, option, False))
return cmd
def apply_option(self, cmd, option, active=True):
"""Apply a command-line option."""
return re.sub(r'{{{}\:(?P<option>[^}}]*)}}'.format(option),
r'\g<option>' if active else '', cmd)
class lint(BaseCommand):
"""A PEP 8 lint command that optionally fixes violations."""
description = 'check code against PEP 8 (and fix violations)'
user_options = [
('branch=', 'b', 'branch or revision to compare against (e.g. master)'),
('fix', 'f', 'fix the violations in place')
]
def initialize_options(self):
"""Set the default options."""
self.branch = 'master'
self.fix = False
super(lint, self).initialize_options()
def run(self):
"""Run the linter."""
cmd = 'pep8radius {branch} {{fix: --in-place}}{{verbose: -vv}}'
cmd = cmd.format(branch=self.branch)
self.call_and_exit(self.apply_options(cmd, ('fix', )))
class test(BaseCommand):
"""Run the test suites for this project."""
description = 'run the test suite'
user_options = [
('all', 'a', 'test against all supported versions of Python'),
('coverage', 'c', 'measure test coverage')
]
unit_test_cmd = ('pytest{quiet: -q}{verbose: -v}{dry_run: --setup-only}'
'{coverage: --cov-report= --cov=cli_helpers}')
test_all_cmd = 'tox{verbose: -v}{dry_run: --notest}'
coverage_cmd = 'coverage report'
def initialize_options(self):
"""Set the default options."""
self.all = False
self.coverage = False
super(test, self).initialize_options()
def run(self):
"""Run the test suites."""
if self.all:
cmd = self.apply_options(self.test_all_cmd)
self.call_and_exit(cmd)
else:
cmds = (self.apply_options(self.unit_test_cmd, ('coverage', )), )
if self.coverage:
cmds += (self.apply_options(self.coverage_cmd), )
self.call_in_sequence(cmds)
class docs(BaseCommand):
"""Use Sphinx Makefile to generate documentation."""
description = 'generate the Sphinx HTML documentation'
clean_docs_cmd = 'make -C docs clean'
html_docs_cmd = 'make -C docs html'
view_docs_cmd = 'open docs/build/html/index.html'
def run(self):
"""Generate and view the documentation."""
cmds = (self.clean_docs_cmd, self.html_docs_cmd, self.view_docs_cmd)
self.call_in_sequence(cmds)

0
tests/__init__.py Normal file
View file

78
tests/compat.py Normal file
View file

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""Python compatibility support for CLI Helpers' tests."""
from __future__ import unicode_literals
import os as _os
import shutil as _shutil
import tempfile as _tempfile
import warnings as _warnings
from cli_helpers.compat import PY2
class _TempDirectory(object):
"""Create and return a temporary directory. This has the same
behavior as mkdtemp but can be used as a context manager. For
example:
with TemporaryDirectory() as tmpdir:
...
Upon exiting the context, the directory and everything contained
in it are removed.
NOTE: Copied from the Python 3 standard library.
"""
# Handle mkdtemp raising an exception
name = None
_closed = False
def __init__(self, suffix="", prefix='tmp', dir=None):
self.name = _tempfile.mkdtemp(suffix, prefix, dir)
def __repr__(self):
return "<{} {!r}>".format(self.__class__.__name__, self.name)
def __enter__(self):
return self.name
def cleanup(self, _warn=False, _warnings=_warnings):
if self.name and not self._closed:
try:
_shutil.rmtree(self.name)
except (TypeError, AttributeError) as ex:
if "None" not in '%s' % (ex,):
raise
self._rmtree(self.name)
self._closed = True
if _warn and _warnings.warn:
_warnings.warn("Implicitly cleaning up {!r}".format(self),
ResourceWarning)
def __exit__(self, exc, value, tb):
self.cleanup()
def __del__(self):
# Issue a ResourceWarning if implicit cleanup needed
self.cleanup(_warn=True)
def _rmtree(self, path, _OSError=OSError, _sep=_os.path.sep,
_listdir=_os.listdir, _remove=_os.remove, _rmdir=_os.rmdir):
# Essentially a stripped down version of shutil.rmtree. We can't
# use globals because they may be None'ed out at shutdown.
if not isinstance(path, str):
_sep = _sep.encode()
try:
for name in _listdir(path):
fullname = path + _sep + name
try:
_remove(fullname)
except _OSError:
self._rmtree(fullname)
_rmdir(path)
except _OSError:
pass
TemporaryDirectory = _TempDirectory if PY2 else _tempfile.TemporaryDirectory

View file

@ -0,0 +1,18 @@
# vi: ft=dosini
# Test file comment
[section]
# Test section comment
# Test field comment
test_boolean_default = True
# Test field commented out
# Uncomment to enable
# test_boolean = True
test_string_file = '~/myfile'
test_option = 'foobar'
[section2]

View file

@ -0,0 +1,20 @@
# vi: ft=dosini
# Test file comment
[section]
# Test section comment
# Test field comment
test_boolean_default = boolean(default=True)
test_boolean = boolean()
# Test field commented out
# Uncomment to enable
# test_boolean = True
test_string_file = string(default='~/myfile')
test_option = option('foo', 'bar', 'foobar', default='foobar')
[section2]

View file

@ -0,0 +1,18 @@
# vi: ft=dosini
# Test file comment
[section]
# Test section comment
# Test field comment
test_boolean_default True
# Test field commented out
# Uncomment to enable
# test_boolean = True
test_string_file = '~/myfile'
test_option = 'foobar'
[section2]

View file

@ -0,0 +1,20 @@
# vi: ft=dosini
# Test file comment
[section]
# Test section comment
# Test field comment
test_boolean_default = boolean(default=True)
test_boolean = bool(default=False)
# Test field commented out
# Uncomment to enable
# test_boolean = True
test_string_file = string(default='~/myfile')
test_option = option('foo', 'bar', 'foobar', default='foobar')
[section2]

View file

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""Test the delimited output adapter."""
from __future__ import unicode_literals
from textwrap import dedent
import pytest
from cli_helpers.tabular_output import delimited_output_adapter
def test_csv_wrapper():
"""Test the delimited output adapter."""
# Test comma-delimited output.
data = [['abc', '1'], ['d', '456']]
headers = ['letters', 'number']
output = delimited_output_adapter.adapter(iter(data), headers, dialect='unix')
assert "\n".join(output) == dedent('''\
"letters","number"\n\
"abc","1"\n\
"d","456"''')
# Test tab-delimited output.
data = [['abc', '1'], ['d', '456']]
headers = ['letters', 'number']
output = delimited_output_adapter.adapter(
iter(data), headers, table_format='csv-tab', dialect='unix')
assert "\n".join(output) == dedent('''\
"letters"\t"number"\n\
"abc"\t"1"\n\
"d"\t"456"''')
with pytest.raises(ValueError):
output = delimited_output_adapter.adapter(
iter(data), headers, table_format='foobar')
list(output)
def test_unicode_with_csv():
"""Test that the csv wrapper can handle non-ascii characters."""
data = [['观音', '1'], ['Ποσειδῶν', '456']]
headers = ['letters', 'number']
output = delimited_output_adapter.adapter(data, headers)
assert "\n".join(output) == dedent('''\
letters,number\n\
观音,1\n\
Ποσειδῶν,456''')

View file

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""Test the generic output formatter interface."""
from __future__ import unicode_literals
from decimal import Decimal
from textwrap import dedent
import pytest
from cli_helpers.tabular_output import format_output, TabularOutputFormatter
from cli_helpers.compat import binary_type, text_type
from cli_helpers.utils import strip_ansi
def test_tabular_output_formatter():
"""Test the TabularOutputFormatter class."""
headers = ['text', 'numeric']
data = [
["abc", Decimal(1)],
["defg", Decimal("11.1")],
["hi", Decimal("1.1")],
["Pablo\rß\n", 0],
]
expected = dedent("""\
+------------+---------+
| text | numeric |
+------------+---------+
| abc | 1 |
| defg | 11.1 |
| hi | 1.1 |
| Pablo\\\\n | 0 |
+------------+---------+"""
)
print(expected)
print("\n".join(TabularOutputFormatter().format_output(
iter(data), headers, format_name='ascii')))
assert expected == "\n".join(TabularOutputFormatter().format_output(
iter(data), headers, format_name='ascii'))
def test_tabular_format_output_wrapper():
"""Test the format_output wrapper."""
data = [['1', None], ['2', 'Sam'],
['3', 'Joe']]
headers = ['id', 'name']
expected = dedent('''\
+----+------+
| id | name |
+----+------+
| 1 | N/A |
| 2 | Sam |
| 3 | Joe |
+----+------+''')
assert expected == "\n".join(format_output(iter(data), headers, format_name='ascii',
missing_value='N/A'))
def test_additional_preprocessors():
"""Test that additional preprocessors are run."""
def hello_world(data, headers, **_):
def hello_world_data(data):
for row in data:
for i, value in enumerate(row):
if value == 'hello':
row[i] = "{}, world".format(value)
yield row
return hello_world_data(data), headers
data = [['foo', None], ['hello!', 'hello']]
headers = 'ab'
expected = dedent('''\
+--------+--------------+
| a | b |
+--------+--------------+
| foo | hello |
| hello! | hello, world |
+--------+--------------+''')
assert expected == "\n".join(TabularOutputFormatter().format_output(
iter(data), headers, format_name='ascii', preprocessors=(hello_world,),
missing_value='hello'))
def test_format_name_attribute():
"""Test the the format_name attribute be set and retrieved."""
formatter = TabularOutputFormatter(format_name='plain')
assert formatter.format_name == 'plain'
formatter.format_name = 'simple'
assert formatter.format_name == 'simple'
with pytest.raises(ValueError):
formatter.format_name = 'foobar'
def test_unsupported_format():
"""Test that TabularOutputFormatter rejects unknown formats."""
formatter = TabularOutputFormatter()
with pytest.raises(ValueError):
formatter.format_name = 'foobar'
with pytest.raises(ValueError):
formatter.format_output((), (), format_name='foobar')
def test_tabulate_ansi_escape_in_default_value():
"""Test that ANSI escape codes work with tabulate."""
data = [['1', None], ['2', 'Sam'],
['3', 'Joe']]
headers = ['id', 'name']
styled = format_output(iter(data), headers, format_name='psql',
missing_value='\x1b[38;5;10mNULL\x1b[39m')
unstyled = format_output(iter(data), headers, format_name='psql',
missing_value='NULL')
stripped_styled = [strip_ansi(s) for s in styled]
assert list(unstyled) == stripped_styled
def test_get_type():
"""Test that _get_type returns the expected type."""
formatter = TabularOutputFormatter()
tests = ((1, int), (2.0, float), (b'binary', binary_type),
('text', text_type), (None, type(None)), ((), text_type))
for value, data_type in tests:
assert data_type is formatter._get_type(value)
def test_provide_column_types():
"""Test that provided column types are passed to preprocessors."""
expected_column_types = (bool, float)
data = ((1, 1.0), (0, 2))
headers = ('a', 'b')
def preprocessor(data, headers, column_types=(), **_):
assert expected_column_types == column_types
return data, headers
format_output(data, headers, 'csv',
column_types=expected_column_types,
preprocessors=(preprocessor,))
def test_enforce_iterable():
"""Test that all output formatters accept iterable"""
formatter = TabularOutputFormatter()
loremipsum = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod'.split(' ')
for format_name in formatter.supported_formats:
formatter.format_name = format_name
try:
formatted = next(formatter.format_output(
zip(loremipsum), ['lorem']))
except TypeError:
assert False, "{0} doesn't return iterable".format(format_name)
def test_all_text_type():
"""Test the TabularOutputFormatter class."""
data = [[1, u"", None, Decimal(2)]]
headers = ['col1', 'col2', 'col3', 'col4']
output_formatter = TabularOutputFormatter()
for format_name in output_formatter.supported_formats:
for row in output_formatter.format_output(iter(data), headers, format_name=format_name):
assert isinstance(row, text_type), "not unicode for {}".format(format_name)

View file

@ -0,0 +1,334 @@
# -*- coding: utf-8 -*-
"""Test CLI Helpers' tabular output preprocessors."""
from __future__ import unicode_literals
from decimal import Decimal
import pytest
from cli_helpers.compat import HAS_PYGMENTS
from cli_helpers.tabular_output.preprocessors import (
align_decimals, bytes_to_string, convert_to_string, quote_whitespaces,
override_missing_value, override_tab_value, style_output, format_numbers)
if HAS_PYGMENTS:
from pygments.style import Style
from pygments.token import Token
import inspect
import cli_helpers.tabular_output.preprocessors
import types
def test_convert_to_string():
"""Test the convert_to_string() function."""
data = [[1, 'John'], [2, 'Jill']]
headers = [0, 'name']
expected = ([['1', 'John'], ['2', 'Jill']], ['0', 'name'])
results = convert_to_string(data, headers)
assert expected == (list(results[0]), results[1])
def test_override_missing_values():
"""Test the override_missing_values() function."""
data = [[1, None], [2, 'Jill']]
headers = [0, 'name']
expected = ([[1, '<EMPTY>'], [2, 'Jill']], [0, 'name'])
results = override_missing_value(data, headers, missing_value='<EMPTY>')
assert expected == (list(results[0]), results[1])
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_override_missing_value_with_style():
"""Test that *override_missing_value()* styles output."""
class NullStyle(Style):
styles = {
Token.Output.Null: '#0f0'
}
headers = ['h1', 'h2']
data = [[None, '2'], ['abc', None]]
expected_headers = ['h1', 'h2']
expected_data = [
['\x1b[38;5;10m<null>\x1b[39m', '2'],
['abc', '\x1b[38;5;10m<null>\x1b[39m']
]
results = override_missing_value(data, headers,
style=NullStyle, missing_value="<null>")
assert (expected_data, expected_headers) == (list(results[0]), results[1])
def test_override_tab_value():
"""Test the override_tab_value() function."""
data = [[1, '\tJohn'], [2, 'Jill']]
headers = ['id', 'name']
expected = ([[1, ' John'], [2, 'Jill']], ['id', 'name'])
results = override_tab_value(data, headers)
assert expected == (list(results[0]), results[1])
def test_bytes_to_string():
"""Test the bytes_to_string() function."""
data = [[1, 'John'], [2, b'Jill']]
headers = [0, 'name']
expected = ([[1, 'John'], [2, 'Jill']], [0, 'name'])
results = bytes_to_string(data, headers)
assert expected == (list(results[0]), results[1])
def test_align_decimals():
"""Test the align_decimals() function."""
data = [[Decimal('200'), Decimal('1')], [
Decimal('1.00002'), Decimal('1.0')]]
headers = ['num1', 'num2']
column_types = (float, float)
expected = ([['200', '1'], [' 1.00002', '1.0']], ['num1', 'num2'])
results = align_decimals(data, headers, column_types=column_types)
assert expected == (list(results[0]), results[1])
def test_align_decimals_empty_result():
"""Test align_decimals() with no results."""
data = []
headers = ['num1', 'num2']
column_types = ()
expected = ([], ['num1', 'num2'])
results = align_decimals(data, headers, column_types=column_types)
assert expected == (list(results[0]), results[1])
def test_align_decimals_non_decimals():
"""Test align_decimals() with non-decimals."""
data = [[Decimal('200.000'), Decimal('1.000')], [None, None]]
headers = ['num1', 'num2']
column_types = (float, float)
expected = ([['200.000', '1.000'], [None, None]], ['num1', 'num2'])
results = align_decimals(data, headers, column_types=column_types)
assert expected == (list(results[0]), results[1])
def test_quote_whitespaces():
"""Test the quote_whitespaces() function."""
data = [[" before", "after "], [" both ", "none"]]
headers = ['h1', 'h2']
expected = ([["' before'", "'after '"], ["' both '", "'none'"]],
['h1', 'h2'])
results = quote_whitespaces(data, headers)
assert expected == (list(results[0]), results[1])
def test_quote_whitespaces_empty_result():
"""Test the quote_whitespaces() function with no results."""
data = []
headers = ['h1', 'h2']
expected = ([], ['h1', 'h2'])
results = quote_whitespaces(data, headers)
assert expected == (list(results[0]), results[1])
def test_quote_whitespaces_non_spaces():
"""Test the quote_whitespaces() function with non-spaces."""
data = [["\tbefore", "after \r"], ["\n both ", "none"]]
headers = ['h1', 'h2']
expected = ([["'\tbefore'", "'after \r'"], ["'\n both '", "'none'"]],
['h1', 'h2'])
results = quote_whitespaces(data, headers)
assert expected == (list(results[0]), results[1])
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output_no_styles():
"""Test that *style_output()* does not style without styles."""
headers = ['h1', 'h2']
data = [['1', '2'], ['a', 'b']]
results = style_output(data, headers)
assert (data, headers) == (list(results[0]), results[1])
@pytest.mark.skipif(HAS_PYGMENTS,
reason='requires the Pygments library be missing')
def test_style_output_no_pygments():
"""Test that *style_output()* does not try to style without Pygments."""
headers = ['h1', 'h2']
data = [['1', '2'], ['a', 'b']]
results = style_output(data, headers)
assert (data, headers) == (list(results[0]), results[1])
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output():
"""Test that *style_output()* styles output."""
class CliStyle(Style):
default_style = ""
styles = {
Token.Output.Header: 'bold ansibrightred',
Token.Output.OddRow: 'bg:#eee #111',
Token.Output.EvenRow: '#0f0'
}
headers = ['h1', 'h2']
data = [['观音', '2'], ['Ποσειδῶν', 'b']]
expected_headers = ['\x1b[91;01mh1\x1b[39;00m', '\x1b[91;01mh2\x1b[39;00m']
expected_data = [['\x1b[38;5;233;48;5;7m观音\x1b[39;49m',
'\x1b[38;5;233;48;5;7m2\x1b[39;49m'],
['\x1b[38;5;10mΠοσειδῶν\x1b[39m', '\x1b[38;5;10mb\x1b[39m']]
results = style_output(data, headers, style=CliStyle)
assert (expected_data, expected_headers) == (list(results[0]), results[1])
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output_with_newlines():
"""Test that *style_output()* styles output with newlines in it."""
class CliStyle(Style):
default_style = ""
styles = {
Token.Output.Header: 'bold ansibrightred',
Token.Output.OddRow: 'bg:#eee #111',
Token.Output.EvenRow: '#0f0'
}
headers = ['h1', 'h2']
data = [['观音\nLine2', 'Ποσειδῶν']]
expected_headers = ['\x1b[91;01mh1\x1b[39;00m', '\x1b[91;01mh2\x1b[39;00m']
expected_data = [
['\x1b[38;5;233;48;5;7m观音\x1b[39;49m\n\x1b[38;5;233;48;5;7m'
'Line2\x1b[39;49m',
'\x1b[38;5;233;48;5;7mΠοσειδῶν\x1b[39;49m']]
results = style_output(data, headers, style=CliStyle)
assert (expected_data, expected_headers) == (list(results[0]), results[1])
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output_custom_tokens():
"""Test that *style_output()* styles output with custom token names."""
class CliStyle(Style):
default_style = ""
styles = {
Token.Results.Headers: 'bold ansibrightred',
Token.Results.OddRows: 'bg:#eee #111',
Token.Results.EvenRows: '#0f0'
}
headers = ['h1', 'h2']
data = [['1', '2'], ['a', 'b']]
expected_headers = ['\x1b[91;01mh1\x1b[39;00m', '\x1b[91;01mh2\x1b[39;00m']
expected_data = [['\x1b[38;5;233;48;5;7m1\x1b[39;49m',
'\x1b[38;5;233;48;5;7m2\x1b[39;49m'],
['\x1b[38;5;10ma\x1b[39m', '\x1b[38;5;10mb\x1b[39m']]
output = style_output(
data, headers, style=CliStyle,
header_token='Token.Results.Headers',
odd_row_token='Token.Results.OddRows',
even_row_token='Token.Results.EvenRows')
assert (expected_data, expected_headers) == (list(output[0]), output[1])
def test_format_integer():
"""Test formatting for an INTEGER datatype."""
data = [[1], [1000], [1000000]]
headers = ['h1']
result_data, result_headers = format_numbers(data,
headers,
column_types=(int,),
integer_format=',',
float_format=',')
expected = [['1'], ['1,000'], ['1,000,000']]
assert expected == list(result_data)
assert headers == result_headers
def test_format_decimal():
"""Test formatting for a DECIMAL(12, 4) datatype."""
data = [[Decimal('1.0000')], [Decimal('1000.0000')], [Decimal('1000000.0000')]]
headers = ['h1']
result_data, result_headers = format_numbers(data,
headers,
column_types=(float,),
integer_format=',',
float_format=',')
expected = [['1.0000'], ['1,000.0000'], ['1,000,000.0000']]
assert expected == list(result_data)
assert headers == result_headers
def test_format_float():
"""Test formatting for a REAL datatype."""
data = [[1.0], [1000.0], [1000000.0]]
headers = ['h1']
result_data, result_headers = format_numbers(data,
headers,
column_types=(float,),
integer_format=',',
float_format=',')
expected = [['1.0'], ['1,000.0'], ['1,000,000.0']]
assert expected == list(result_data)
assert headers == result_headers
def test_format_integer_only():
"""Test that providing one format string works."""
data = [[1, 1.0], [1000, 1000.0], [1000000, 1000000.0]]
headers = ['h1', 'h2']
result_data, result_headers = format_numbers(data, headers, column_types=(int, float),
integer_format=',')
expected = [['1', 1.0], ['1,000', 1000.0], ['1,000,000', 1000000.0]]
assert expected == list(result_data)
assert headers == result_headers
def test_format_numbers_no_format_strings():
"""Test that numbers aren't formatted without format strings."""
data = ((1), (1000), (1000000))
headers = ('h1',)
result_data, result_headers = format_numbers(data, headers, column_types=(int,))
assert list(data) == list(result_data)
assert headers == result_headers
def test_format_numbers_no_column_types():
"""Test that numbers aren't formatted without column types."""
data = ((1), (1000), (1000000))
headers = ('h1',)
result_data, result_headers = format_numbers(data, headers, integer_format=',',
float_format=',')
assert list(data) == list(result_data)
assert headers == result_headers
def test_enforce_iterable():
preprocessors = inspect.getmembers(cli_helpers.tabular_output.preprocessors, inspect.isfunction)
loremipsum = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod'.split(' ')
for name, preprocessor in preprocessors:
preprocessed = preprocessor(zip(loremipsum), ['lorem'], column_types=(str,))
try:
first = next(preprocessed[0])
except StopIteration:
assert False, "{} gives no output with iterator data".format(name)
except TypeError:
assert False, "{} doesn't return iterable".format(name)
if isinstance(preprocessed[1], types.GeneratorType):
assert False, "{} returns headers as iterator".format(name)

View file

@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""Test the tabulate output adapter."""
from __future__ import unicode_literals
from textwrap import dedent
import pytest
from cli_helpers.compat import HAS_PYGMENTS
from cli_helpers.tabular_output import tabulate_adapter
if HAS_PYGMENTS:
from pygments.style import Style
from pygments.token import Token
def test_tabulate_wrapper():
"""Test the *output_formatter.tabulate_wrapper()* function."""
data = [['abc', 1], ['d', 456]]
headers = ['letters', 'number']
output = tabulate_adapter.adapter(iter(data), headers, table_format='psql')
assert "\n".join(output) == dedent('''\
+-----------+----------+
| letters | number |
|-----------+----------|
| abc | 1 |
| d | 456 |
+-----------+----------+''')
data = [['{1,2,3}', '{{1,2},{3,4}}', '{å,魚,текст}'], ['{}', '<null>', '{<null>}']]
headers = ['bigint_array', 'nested_numeric_array', '配列']
output = tabulate_adapter.adapter(iter(data), headers, table_format='psql')
assert "\n".join(output) == dedent('''\
+----------------+------------------------+--------------+
| bigint_array | nested_numeric_array | 配列 |
|----------------+------------------------+--------------|
| {1,2,3} | {{1,2},{3,4}} | {å,,текст} |
| {} | <null> | {<null>} |
+----------------+------------------------+--------------+''')
def test_markup_format():
"""Test that markup formats do not have number align or string align."""
data = [['abc', 1], ['d', 456]]
headers = ['letters', 'number']
output = tabulate_adapter.adapter(iter(data), headers, table_format='mediawiki')
assert "\n".join(output) == dedent('''\
{| class="wikitable" style="text-align: left;"
|+ <!-- caption -->
|-
! letters !! number
|-
| abc || 1
|-
| d || 456
|}''')
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output_table():
"""Test that *style_output_table()* styles the output table."""
class CliStyle(Style):
default_style = ""
styles = {
Token.Output.TableSeparator: 'ansibrightred',
}
headers = ['h1', 'h2']
data = [['观音', '2'], ['Ποσειδῶν', 'b']]
style_output_table = tabulate_adapter.style_output_table('psql')
style_output_table(data, headers, style=CliStyle)
output = tabulate_adapter.adapter(iter(data), headers, table_format='psql')
assert "\n".join(output) == dedent('''\
\x1b[91m+\x1b[39m''' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 6)) +
'''\x1b[91m+\x1b[39m
\x1b[91m|\x1b[39m h1 \x1b[91m|\x1b[39m''' +
''' h2 \x1b[91m|\x1b[39m
''' + '\x1b[91m|\x1b[39m' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 6)) +
'''\x1b[91m|\x1b[39m
\x1b[91m|\x1b[39m 观音 \x1b[91m|\x1b[39m''' +
''' 2 \x1b[91m|\x1b[39m
\x1b[91m|\x1b[39m Ποσειδῶν \x1b[91m|\x1b[39m''' +
''' b \x1b[91m|\x1b[39m
''' + '\x1b[91m+\x1b[39m' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 6)) +
'\x1b[91m+\x1b[39m')

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Test the terminaltables output adapter."""
from __future__ import unicode_literals
from textwrap import dedent
import pytest
from cli_helpers.compat import HAS_PYGMENTS
from cli_helpers.tabular_output import terminaltables_adapter
if HAS_PYGMENTS:
from pygments.style import Style
from pygments.token import Token
def test_terminal_tables_adapter():
"""Test the terminaltables output adapter."""
data = [['abc', 1], ['d', 456]]
headers = ['letters', 'number']
output = terminaltables_adapter.adapter(
iter(data), headers, table_format='ascii')
assert "\n".join(output) == dedent('''\
+---------+--------+
| letters | number |
+---------+--------+
| abc | 1 |
| d | 456 |
+---------+--------+''')
@pytest.mark.skipif(not HAS_PYGMENTS, reason='requires the Pygments library')
def test_style_output_table():
"""Test that *style_output_table()* styles the output table."""
class CliStyle(Style):
default_style = ""
styles = {
Token.Output.TableSeparator: 'ansibrightred',
}
headers = ['h1', 'h2']
data = [['观音', '2'], ['Ποσειδῶν', 'b']]
style_output_table = terminaltables_adapter.style_output_table('ascii')
style_output_table(data, headers, style=CliStyle)
output = terminaltables_adapter.adapter(iter(data), headers, table_format='ascii')
assert "\n".join(output) == dedent('''\
\x1b[91m+\x1b[39m''' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 4)) +
'''\x1b[91m+\x1b[39m
\x1b[91m|\x1b[39m h1 \x1b[91m|\x1b[39m''' +
''' h2 \x1b[91m|\x1b[39m
''' + '\x1b[91m+\x1b[39m' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 4)) +
'''\x1b[91m+\x1b[39m
\x1b[91m|\x1b[39m 观音 \x1b[91m|\x1b[39m''' +
''' 2 \x1b[91m|\x1b[39m
\x1b[91m|\x1b[39m Ποσειδῶν \x1b[91m|\x1b[39m''' +
''' b \x1b[91m|\x1b[39m
''' + '\x1b[91m+\x1b[39m' + (
('\x1b[91m-\x1b[39m' * 10) +
'\x1b[91m+\x1b[39m' +
('\x1b[91m-\x1b[39m' * 4)) +
'\x1b[91m+\x1b[39m')

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""Test the tsv delimited output adapter."""
from __future__ import unicode_literals
from textwrap import dedent
import pytest
from cli_helpers.tabular_output import tsv_output_adapter
def test_tsv_wrapper():
"""Test the tsv output adapter."""
# Test tab-delimited output.
data = [['ab\r\nc', '1'], ['d', '456']]
headers = ['letters', 'number']
output = tsv_output_adapter.adapter(
iter(data), headers, table_format='tsv')
assert "\n".join(output) == dedent('''\
letters\tnumber\n\
ab\r\\nc\t1\n\
d\t456''')
def test_unicode_with_tsv():
"""Test that the tsv wrapper can handle non-ascii characters."""
data = [['观音', '1'], ['Ποσειδῶν', '456']]
headers = ['letters', 'number']
output = tsv_output_adapter.adapter(data, headers)
assert "\n".join(output) == dedent('''\
letters\tnumber\n\
观音\t1\n\
Ποσειδῶν\t456''')

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""Test the vertical table formatter."""
from textwrap import dedent
from cli_helpers.compat import text_type
from cli_helpers.tabular_output import vertical_table_adapter
def test_vertical_table():
"""Test the default settings for vertical_table()."""
results = [('hello', text_type(123)), ('world', text_type(456))]
expected = dedent("""\
***************************[ 1. row ]***************************
name | hello
age | 123
***************************[ 2. row ]***************************
name | world
age | 456""")
assert expected == "\n".join(
vertical_table_adapter.adapter(results, ('name', 'age')))
def test_vertical_table_customized():
"""Test customized settings for vertical_table()."""
results = [('john', text_type(47)), ('jill', text_type(50))]
expected = dedent("""\
-[ PERSON 1 ]-----
name | john
age | 47
-[ PERSON 2 ]-----
name | jill
age | 50""")
assert expected == "\n".join(vertical_table_adapter.adapter(
results, ('name', 'age'), sep_title='PERSON {n}',
sep_character='-', sep_length=(1, 5)))

View file

@ -0,0 +1,5 @@
import cli_helpers
def test_cli_helpers():
assert True

271
tests/test_config.py Normal file
View file

@ -0,0 +1,271 @@
# -*- coding: utf-8 -*-
"""Test the cli_helpers.config module."""
from __future__ import unicode_literals
import os
from unittest.mock import MagicMock
import pytest
from cli_helpers.compat import MAC, text_type, WIN
from cli_helpers.config import (Config, DefaultConfigValidationError,
get_system_config_dirs, get_user_config_dir,
_pathify)
from .utils import with_temp_dir
APP_NAME, APP_AUTHOR = 'Test', 'Acme'
TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'config_data')
DEFAULT_CONFIG = {
'section': {
'test_boolean_default': 'True',
'test_string_file': '~/myfile',
'test_option': 'foobar'
},
'section2': {}
}
DEFAULT_VALID_CONFIG = {
'section': {
'test_boolean_default': True,
'test_string_file': '~/myfile',
'test_option': 'foobar'
},
'section2': {}
}
def _mocked_user_config(temp_dir, *args, **kwargs):
config = Config(*args, **kwargs)
config.user_config_file = MagicMock(return_value=os.path.join(
temp_dir, config.filename))
return config
def test_user_config_dir():
"""Test that the config directory is a string with the app name in it."""
if 'XDG_CONFIG_HOME' in os.environ:
del os.environ['XDG_CONFIG_HOME']
config_dir = get_user_config_dir(APP_NAME, APP_AUTHOR)
assert isinstance(config_dir, text_type)
assert (config_dir.endswith(APP_NAME) or
config_dir.endswith(_pathify(APP_NAME)))
def test_sys_config_dirs():
"""Test that the sys config directories are returned correctly."""
if 'XDG_CONFIG_DIRS' in os.environ:
del os.environ['XDG_CONFIG_DIRS']
config_dirs = get_system_config_dirs(APP_NAME, APP_AUTHOR)
assert isinstance(config_dirs, list)
assert (config_dirs[0].endswith(APP_NAME) or
config_dirs[0].endswith(_pathify(APP_NAME)))
@pytest.mark.skipif(not WIN, reason="requires Windows")
def test_windows_user_config_dir_no_roaming():
"""Test that Windows returns the user config directory without roaming."""
config_dir = get_user_config_dir(APP_NAME, APP_AUTHOR, roaming=False)
assert isinstance(config_dir, text_type)
assert config_dir.endswith(APP_NAME)
assert 'Local' in config_dir
@pytest.mark.skipif(not MAC, reason="requires macOS")
def test_mac_user_config_dir_no_xdg():
"""Test that macOS returns the user config directory without XDG."""
config_dir = get_user_config_dir(APP_NAME, APP_AUTHOR, force_xdg=False)
assert isinstance(config_dir, text_type)
assert config_dir.endswith(APP_NAME)
assert 'Library' in config_dir
@pytest.mark.skipif(not MAC, reason="requires macOS")
def test_mac_system_config_dirs_no_xdg():
"""Test that macOS returns the system config directories without XDG."""
config_dirs = get_system_config_dirs(APP_NAME, APP_AUTHOR, force_xdg=False)
assert isinstance(config_dirs, list)
assert config_dirs[0].endswith(APP_NAME)
assert 'Library' in config_dirs[0]
def test_config_reading_raise_errors():
"""Test that instantiating Config will raise errors when appropriate."""
with pytest.raises(ValueError):
Config(APP_NAME, APP_AUTHOR, 'test_config', write_default=True)
with pytest.raises(ValueError):
Config(APP_NAME, APP_AUTHOR, 'test_config', validate=True)
with pytest.raises(TypeError):
Config(APP_NAME, APP_AUTHOR, 'test_config', default=b'test')
def test_config_user_file():
"""Test that the Config user_config_file is appropriate."""
config = Config(APP_NAME, APP_AUTHOR, 'test_config')
assert (get_user_config_dir(APP_NAME, APP_AUTHOR) in
config.user_config_file())
def test_config_reading_default_dict():
"""Test that the Config constructor will read in defaults from a dict."""
default = {'main': {'foo': 'bar'}}
config = Config(APP_NAME, APP_AUTHOR, 'test_config', default=default)
assert config.data == default
def test_config_reading_no_default():
"""Test that the Config constructor will work without any defaults."""
config = Config(APP_NAME, APP_AUTHOR, 'test_config')
assert config.data == {}
def test_config_reading_default_file():
"""Test that the Config will work with a default file."""
config = Config(APP_NAME, APP_AUTHOR, 'test_config',
default=os.path.join(TEST_DATA_DIR, 'configrc'))
config.read_default_config()
assert config.data == DEFAULT_CONFIG
def test_config_reading_configspec():
"""Test that the Config default file will work with a configspec."""
config = Config(APP_NAME, APP_AUTHOR, 'test_config', validate=True,
default=os.path.join(TEST_DATA_DIR, 'configspecrc'))
config.read_default_config()
assert config.data == DEFAULT_VALID_CONFIG
def test_config_reading_configspec_with_error():
"""Test that reading an invalid configspec raises and exception."""
with pytest.raises(DefaultConfigValidationError):
config = Config(APP_NAME, APP_AUTHOR, 'test_config', validate=True,
default=os.path.join(TEST_DATA_DIR,
'invalid_configspecrc'))
config.read_default_config()
@with_temp_dir
def test_write_and_read_default_config(temp_dir=None):
config_file = 'test_config'
default_file = os.path.join(TEST_DATA_DIR, 'configrc')
temp_config_file = os.path.join(temp_dir, config_file)
config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR, config_file,
default=default_file)
config.read_default_config()
config.write_default_config()
user_config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR,
config_file, default=default_file)
user_config.read()
assert temp_config_file in user_config.config_filenames
assert user_config == config
with open(temp_config_file) as f:
contents = f.read()
assert '# Test file comment' in contents
assert '# Test section comment' in contents
assert '# Test field comment' in contents
assert '# Test field commented out' in contents
@with_temp_dir
def test_write_and_read_default_config_from_configspec(temp_dir=None):
config_file = 'test_config'
default_file = os.path.join(TEST_DATA_DIR, 'configspecrc')
temp_config_file = os.path.join(temp_dir, config_file)
config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR, config_file,
default=default_file, validate=True)
config.read_default_config()
config.write_default_config()
user_config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR,
config_file, default=default_file,
validate=True)
user_config.read()
assert temp_config_file in user_config.config_filenames
assert user_config == config
with open(temp_config_file) as f:
contents = f.read()
assert '# Test file comment' in contents
assert '# Test section comment' in contents
assert '# Test field comment' in contents
assert '# Test field commented out' in contents
@with_temp_dir
def test_overwrite_default_config_from_configspec(temp_dir=None):
config_file = 'test_config'
default_file = os.path.join(TEST_DATA_DIR, 'configspecrc')
temp_config_file = os.path.join(temp_dir, config_file)
config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR, config_file,
default=default_file, validate=True)
config.read_default_config()
config.write_default_config()
with open(temp_config_file, 'a') as f:
f.write('--APPEND--')
config.write_default_config()
with open(temp_config_file) as f:
assert '--APPEND--' in f.read()
config.write_default_config(overwrite=True)
with open(temp_config_file) as f:
assert '--APPEND--' not in f.read()
def test_read_invalid_config_file():
config_file = 'invalid_configrc'
config = _mocked_user_config(TEST_DATA_DIR, APP_NAME, APP_AUTHOR,
config_file)
config.read()
assert 'section' in config
assert 'test_string_file' in config['section']
assert 'test_boolean_default' not in config['section']
assert 'section2' in config
@with_temp_dir
def test_write_to_user_config(temp_dir=None):
config_file = 'test_config'
default_file = os.path.join(TEST_DATA_DIR, 'configrc')
temp_config_file = os.path.join(temp_dir, config_file)
config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR, config_file,
default=default_file)
config.read_default_config()
config.write_default_config()
with open(temp_config_file) as f:
assert 'test_boolean_default = True' in f.read()
config['section']['test_boolean_default'] = False
config.write()
with open(temp_config_file) as f:
assert 'test_boolean_default = False' in f.read()
@with_temp_dir
def test_write_to_outfile(temp_dir=None):
config_file = 'test_config'
outfile = os.path.join(temp_dir, 'foo')
default_file = os.path.join(TEST_DATA_DIR, 'configrc')
config = _mocked_user_config(temp_dir, APP_NAME, APP_AUTHOR, config_file,
default=default_file)
config.read_default_config()
config.write_default_config()
config['section']['test_boolean_default'] = False
config.write(outfile=outfile)
with open(outfile) as f:
assert 'test_boolean_default = False' in f.read()

70
tests/test_utils.py Normal file
View file

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""Test CLI Helpers' utility functions and helpers."""
from __future__ import unicode_literals
from cli_helpers import utils
def test_bytes_to_string_hexlify():
"""Test that bytes_to_string() hexlifies binary data."""
assert utils.bytes_to_string(b'\xff') == '0xff'
def test_bytes_to_string_decode_bytes():
"""Test that bytes_to_string() decodes bytes."""
assert utils.bytes_to_string(b'foobar') == 'foobar'
def test_bytes_to_string_non_bytes():
"""Test that bytes_to_string() returns non-bytes untouched."""
assert utils.bytes_to_string('abc') == 'abc'
assert utils.bytes_to_string(1) == 1
def test_to_string_bytes():
"""Test that to_string() converts bytes to a string."""
assert utils.to_string(b"foo") == 'foo'
def test_to_string_non_bytes():
"""Test that to_string() converts non-bytes to a string."""
assert utils.to_string(1) == '1'
assert utils.to_string(2.29) == '2.29'
def test_truncate_string():
"""Test string truncate preprocessor."""
val = 'x' * 100
assert utils.truncate_string(val, 10) == 'xxxxxxx...'
val = 'x ' * 100
assert utils.truncate_string(val, 10) == 'x x x x...'
val = 'x' * 100
assert utils.truncate_string(val) == 'x' * 100
val = ['x'] * 100
val[20] = '\n'
str_val = ''.join(val)
assert utils.truncate_string(str_val, 10, skip_multiline_string=True) == str_val
def test_intlen_with_decimal():
"""Test that intlen() counts correctly with a decimal place."""
assert utils.intlen('11.1') == 2
assert utils.intlen('1.1') == 1
def test_intlen_without_decimal():
"""Test that intlen() counts correctly without a decimal place."""
assert utils.intlen('11') == 2
def test_filter_dict_by_key():
"""Test that filter_dict_by_key() filter unwanted items."""
keys = ('foo', 'bar')
d = {'foo': 1, 'foobar': 2}
fd = utils.filter_dict_by_key(d, keys)
assert len(fd) == 1
assert all([k in keys for k in fd])

16
tests/utils.py Normal file
View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""Utility functions for CLI Helpers' tests."""
from __future__ import unicode_literals
from functools import wraps
from .compat import TemporaryDirectory
def with_temp_dir(f):
"""A wrapper that creates and deletes a temporary directory."""
@wraps(f)
def wrapped(*args, **kwargs):
with TemporaryDirectory() as temp_dir:
return f(*args, temp_dir=temp_dir, **kwargs)
return wrapped

59
tox.ini Normal file
View file

@ -0,0 +1,59 @@
[tox]
envlist = cov-init, py36, py37, noextras, docs, packaging, cov-report
[testenv]
passenv = CI TRAVIS TRAVIS_* CODECOV
whitelist_externals =
bash
make
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/cli_helpers
COVERAGE_FILE = .coverage.{envname}
commands =
pytest --cov-report= --cov=cli_helpers
coverage report
pep8radius master
bash -c 'if [ -n "$CODECOV" ]; then {envbindir}/coverage xml && {envbindir}/codecov; fi'
deps = -r{toxinidir}/requirements-dev.txt
usedevelop = True
[testenv:noextras]
commands =
pip uninstall -y Pygments
{[testenv]commands}
[testenv:docs]
changedir = docs
deps = sphinx
whitelist_externals = make
commands =
make clean
make html
make linkcheck
[testenv:packaging]
deps =
check-manifest
readme_renderer[md]
-r{toxinidir}/requirements-dev.txt
commands =
check-manifest --ignore .travis/*
./setup.py sdist
twine check dist/*
./setup.py check -m -s
[testenv:cov-init]
setenv =
COVERAGE_FILE = .coverage
deps = coverage
commands =
coverage erase
[testenv:cov-report]
setenv =
COVERAGE_FILE = .coverage
deps = coverage
commands =
coverage combine
coverage report