diff --git a/CHANGELOG b/CHANGELOG index d5a9fc3..b311455 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/MANIFEST.in b/MANIFEST.in index 66057e3..67df761 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ recursive-include docs *.py recursive-include docs *.rst recursive-include docs Makefile recursive-include tests *.py +include tests/config_data/* diff --git a/README.rst b/README.rst index 5a8132b..4936647 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/cli_helpers/compat.py b/cli_helpers/compat.py index 6170e41..3f67c62 100644 --- a/cli_helpers/compat.py +++ b/cli_helpers/compat.py @@ -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: @@ -13,6 +15,7 @@ long_type = long int_types = (int, long) + from UserDict import UserDict from backports import csv from StringIO import StringIO @@ -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 diff --git a/cli_helpers/config.py b/cli_helpers/config.py new file mode 100644 index 0000000..51db1d0 --- /dev/null +++ b/cli_helpers/config.py @@ -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\\AppData\Roaming\Acme\My App`` + Windows 7 (not roaming): + ``C:\\Users\\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() diff --git a/docs/source/api.rst b/docs/source/api.rst index 5bc43ff..a95d314 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -15,3 +15,9 @@ Preprocessors .. automodule:: cli_helpers.tabular_output.preprocessors :members: + +Config +------ + +.. automodule:: cli_helpers.config + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 5bfd5a1..4944893 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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) } diff --git a/requirements-dev.txt b/requirements-dev.txt index fea6dac..9e67f94 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/setup.py b/setup.py index ee18936..bb3c9cc 100755 --- a/setup.py +++ b/setup.py @@ -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, diff --git a/tests/compat.py b/tests/compat.py new file mode 100644 index 0000000..383963a --- /dev/null +++ b/tests/compat.py @@ -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 diff --git a/tests/config_data/configrc b/tests/config_data/configrc new file mode 100644 index 0000000..2544726 --- /dev/null +++ b/tests/config_data/configrc @@ -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] diff --git a/tests/config_data/configspecrc b/tests/config_data/configspecrc new file mode 100644 index 0000000..35b7777 --- /dev/null +++ b/tests/config_data/configspecrc @@ -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] diff --git a/tests/config_data/invalid_configrc b/tests/config_data/invalid_configrc new file mode 100644 index 0000000..271c9c5 --- /dev/null +++ b/tests/config_data/invalid_configrc @@ -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] diff --git a/tests/config_data/invalid_configspecrc b/tests/config_data/invalid_configspecrc new file mode 100644 index 0000000..551473f --- /dev/null +++ b/tests/config_data/invalid_configspecrc @@ -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] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3cbe211 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +"""Test the cli_helpers.config module.""" + +from __future__ import unicode_literals +import os + +from 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() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..df62e01 --- /dev/null +++ b/tests/utils.py @@ -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