Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
241a3a0
Add configobj dependency.
tsroten Apr 22, 2017
8113d0e
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten May 4, 2017
5daa0ba
Add windows constant and UserDict to compat module.
tsroten May 14, 2017
4711a03
Add config directory functions.
tsroten May 14, 2017
eb37880
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten May 15, 2017
b0606f3
Initial config reading commit.
tsroten May 16, 2017
df8c5c9
Move test utils to separate module.
tsroten May 16, 2017
e1f413c
Add mock for Python 2.
tsroten May 16, 2017
c20e5be
Fix Python 2 tests.
tsroten May 16, 2017
2576327
Remove unused fake file.
tsroten May 16, 2017
9ec7fd5
Remove unused module.
tsroten May 16, 2017
1bd0c98
Add MAC to compat module.
tsroten May 16, 2017
9cd0cc3
Add test for overwriting user config file.
tsroten May 16, 2017
1167984
Test user config filename.
tsroten May 16, 2017
0f35ae8
Add second section to test config.
tsroten May 16, 2017
1d58a4b
Test reading invalid config file.
tsroten May 19, 2017
4ab5089
Allow for inheriting from Config class.
tsroten May 21, 2017
2a7a9b3
Add Config.write() method.
tsroten May 21, 2017
b83aec8
Use temp dir for test file.
tsroten May 21, 2017
cda03ca
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten May 29, 2017
f745126
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Jun 22, 2017
1e3b332
Don't read config file automatically.
tsroten Jun 22, 2017
bf9215b
Add tests/config_data to manifest file.
tsroten Jun 22, 2017
0dab0e4
Refactor raising validation errors.
tsroten Jun 23, 2017
f86d294
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Jun 23, 2017
03433f8
Shorten the write function.
tsroten Jun 23, 2017
1be63b4
Shorten write method even more.
tsroten Jun 23, 2017
fb88517
Don't call expanduser unnecessarily.
tsroten Jun 23, 2017
5236c11
Break out setting default.
tsroten Jun 23, 2017
8f1b437
Add macOS to travis.
tsroten Jun 23, 2017
110c951
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Jun 23, 2017
dab3841
Revert "Add macOS to travis."
tsroten Jun 24, 2017
449d07f
Add more docstrings.
tsroten Jun 24, 2017
913fe07
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Jul 9, 2017
4c846dc
Initial doc work for config.
tsroten Jul 10, 2017
fc8aa0d
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Aug 4, 2017
61f56d0
Fix config test.
tsroten Aug 4, 2017
100e72e
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten Jan 14, 2018
401db81
correct docstring.
tsroten Feb 13, 2018
e39e685
Update changelog.
tsroten Feb 13, 2018
192e991
Update readme.
tsroten Feb 13, 2018
fbd035c
Fix autopep8 bug
tsroten Feb 13, 2018
20c637d
Merge branch 'master' of github.com:dbcli/cli_helpers into feature/co…
tsroten May 27, 2018
8a8fda9
Combine some of the code into the same methods.
tsroten May 27, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Version 1.0.2

(released on 2018-04-07)

* Adds config file reading/writing.
* Copy unit test from pgcli
* Use safe float for unit test
* Move strip_ansi from tests.utils to cli_helpers.utils
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ recursive-include docs *.py
recursive-include docs *.rst
recursive-include docs Makefile
recursive-include tests *.py
include tests/config_data/*
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ easy to extend.
What's included in CLI Helpers?

- Prettyprinting of tabular data with custom pre-processing
- *[in progress]* config file reading/writing
- Config file reading/writing

.. end-body

Expand Down
6 changes: 5 additions & 1 deletion cli_helpers/compat.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
"""Python 2/3 compatibility support."""
"""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:
Expand All @@ -13,6 +15,7 @@
long_type = long
int_types = (int, long)

from UserDict import UserDict
from backports import csv

from StringIO import StringIO
Expand All @@ -23,6 +26,7 @@
long_type = int
int_types = (int,)

from collections import UserDict
import csv
from io import StringIO
from itertools import zip_longest
Expand Down
270 changes: 270 additions & 0 deletions cli_helpers/config.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ Preprocessors

.. automodule:: cli_helpers.tabular_output.preprocessors
:members:

Config
------

.. automodule:: cli_helpers.config
:members:
3 changes: 2 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,5 +195,6 @@
'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)
'numpy': ('https://docs.scipy.org/doc/numpy', None),
'configobj': ('https://configobj.readthedocs.io/en/latest', None)
}
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
autopep8==1.3.3
codecov==2.0.9
coverage==4.3.4
mock==2.0.0
pep8radius
pytest==3.0.7
pytest-cov==2.4.0
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def open_file(filename):
description='Helpers for building command-line apps',
long_description=readme,
install_requires=[
'configobj >= 5.0.5',
'tabulate[widechars] >= 0.8.2',
'terminaltables >= 3.0.0',
] + py2_reqs,
Expand Down
Loading