Adding upstream version 2.1.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
d1aeef90c9
commit
d8a70e48ab
56 changed files with 3865 additions and 0 deletions
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
13
.gitignore
vendored
Normal 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
30
.travis.yml
Normal 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
25
.travis/install.sh
Executable 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
29
AUTHORS
Normal 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
133
CHANGELOG
Normal 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
102
CONTRIBUTING.rst
Normal 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
27
LICENSE
Normal 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
8
MANIFEST.in
Normal 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
38
README.rst
Normal 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
15
appveyor.yml
Normal 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
1
cli_helpers/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = '2.1.0'
|
42
cli_helpers/compat.py
Normal file
42
cli_helpers/compat.py
Normal 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
270
cli_helpers/config.py
Normal 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()
|
13
cli_helpers/tabular_output/__init__.py
Normal file
13
cli_helpers/tabular_output/__init__.py
Normal 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']
|
48
cli_helpers/tabular_output/delimited_output_adapter.py
Normal file
48
cli_helpers/tabular_output/delimited_output_adapter.py
Normal 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
|
228
cli_helpers/tabular_output/output_formatter.py
Normal file
228
cli_helpers/tabular_output/output_formatter.py
Normal 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})
|
300
cli_helpers/tabular_output/preprocessors.py
Normal file
300
cli_helpers/tabular_output/preprocessors.py
Normal 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
|
99
cli_helpers/tabular_output/tabulate_adapter.py
Normal file
99
cli_helpers/tabular_output/tabulate_adapter.py
Normal 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'))
|
97
cli_helpers/tabular_output/terminaltables_adapter.py
Normal file
97
cli_helpers/tabular_output/terminaltables_adapter.py
Normal 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)
|
16
cli_helpers/tabular_output/tsv_output_adapter.py
Normal file
16
cli_helpers/tabular_output/tsv_output_adapter.py
Normal 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))
|
66
cli_helpers/tabular_output/vertical_table_adapter.py
Normal file
66
cli_helpers/tabular_output/vertical_table_adapter.py
Normal 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
106
cli_helpers/utils.py
Normal 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
20
docs/Makefile
Normal 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
23
docs/source/api.rst
Normal 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
1
docs/source/authors.rst
Normal file
|
@ -0,0 +1 @@
|
|||
.. include:: ../../AUTHORS
|
1
docs/source/changelog.rst
Normal file
1
docs/source/changelog.rst
Normal file
|
@ -0,0 +1 @@
|
|||
.. include:: ../../CHANGELOG
|
200
docs/source/conf.py
Normal file
200
docs/source/conf.py
Normal 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)
|
||||
}
|
1
docs/source/contributing.rst
Normal file
1
docs/source/contributing.rst
Normal file
|
@ -0,0 +1 @@
|
|||
.. include:: ../../CONTRIBUTING.rst
|
30
docs/source/index.rst
Normal file
30
docs/source/index.rst
Normal 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
13
docs/source/license.rst
Normal 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
153
docs/source/quickstart.rst
Normal 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
135
release.py
Normal 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
10
requirements-dev.txt
Normal 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
13
setup.cfg
Normal 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
62
setup.py
Executable 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
122
tasks.py
Normal 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
0
tests/__init__.py
Normal file
78
tests/compat.py
Normal file
78
tests/compat.py
Normal 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
|
18
tests/config_data/configrc
Normal file
18
tests/config_data/configrc
Normal 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]
|
20
tests/config_data/configspecrc
Normal file
20
tests/config_data/configspecrc
Normal 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]
|
18
tests/config_data/invalid_configrc
Normal file
18
tests/config_data/invalid_configrc
Normal 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]
|
20
tests/config_data/invalid_configspecrc
Normal file
20
tests/config_data/invalid_configspecrc
Normal 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]
|
0
tests/tabular_output/__init__.py
Normal file
0
tests/tabular_output/__init__.py
Normal file
48
tests/tabular_output/test_delimited_output_adapter.py
Normal file
48
tests/tabular_output/test_delimited_output_adapter.py
Normal 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''')
|
||||
|
173
tests/tabular_output/test_output_formatter.py
Normal file
173
tests/tabular_output/test_output_formatter.py
Normal 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\\rß\\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)
|
334
tests/tabular_output/test_preprocessors.py
Normal file
334
tests/tabular_output/test_preprocessors.py
Normal 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)
|
96
tests/tabular_output/test_tabulate_adapter.py
Normal file
96
tests/tabular_output/test_tabulate_adapter.py
Normal 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')
|
69
tests/tabular_output/test_terminaltables_adapter.py
Normal file
69
tests/tabular_output/test_terminaltables_adapter.py
Normal 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')
|
33
tests/tabular_output/test_tsv_output_adapter.py
Normal file
33
tests/tabular_output/test_tsv_output_adapter.py
Normal 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''')
|
38
tests/tabular_output/test_vertical_table_adapter.py
Normal file
38
tests/tabular_output/test_vertical_table_adapter.py
Normal 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)))
|
5
tests/test_cli_helpers.py
Normal file
5
tests/test_cli_helpers.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import cli_helpers
|
||||
|
||||
|
||||
def test_cli_helpers():
|
||||
assert True
|
271
tests/test_config.py
Normal file
271
tests/test_config.py
Normal 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
70
tests/test_utils.py
Normal 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
16
tests/utils.py
Normal 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
59
tox.ini
Normal 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
|
Loading…
Add table
Reference in a new issue