Skip to content

Commit

Permalink
Overhaul the ServerConfig class
Browse files Browse the repository at this point in the history
Leave the `ServerConfig` class and its helper, `get_config`, in module
`pulp_smash.config`. However, significantly change them. Change `ServerConfig`
from a dict-like object with several mixins to a plain old object. According to
its docstring:

    This object stores a set of facts that are used when communicating with a
    Pulp server. A typical usage of this object is as follows:

    >>> import requests
    >>> from pulp_smash.config import ServerConfig
    >>> cfg = ServerConfig(
    ...     base_url='https://pulp.example.com',
    ...     auth=('username', 'password'),
    ...     verify=False,  # Disable SSL verification
    ... )
    >>> response = requests.post(
    ...     cfg.base_url + '/pulp/api/v2/actions/login/',
    ...     **cfg.get_requests_kwargs()
    ... )

Fix #12 by only giving the object instance attributes. Fix #16 by making the
class inherit directly from `object`. As a bonus, the class is now entirely
self-contained and reasonably sized, which should aid with comprehension. This
change also targets #13, as each of the file-oriented methods has the ability to
select a particular XDG configuration directory or file if appropriate. For
example, here is `read`'s signature:

    def read(self, section=None, xdg_config_file=None, xdg_config_dir=None):

Update the existing test code to use the new object. Test results:

    $ python -m unittest2 discover pulp_smash.tests
    ....F...
    …
    ----------------------------------------------------------------------
    Ran 8 tests in 4.006s

    FAILED (failures=1)

The one test failure is expected.

Unit test the new class.
  • Loading branch information
Ichimonji10 committed Oct 16, 2015
1 parent bd5824c commit 8f7dea3
Show file tree
Hide file tree
Showing 14 changed files with 543 additions and 413 deletions.
3 changes: 0 additions & 3 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ developers, not a gospel.

api/pulp_smash
api/pulp_smash.config
api/pulp_smash.config.base
api/pulp_smash.config.mixins
api/pulp_smash.tests
api/pulp_smash.tests.test_content_applicability
api/pulp_smash.tests.test_login
api/tests
api/tests.test_config
api/tests.test_config_mixins
7 changes: 0 additions & 7 deletions docs/api/pulp_smash.config.base.rst

This file was deleted.

6 changes: 0 additions & 6 deletions docs/api/pulp_smash.config.mixins.rst

This file was deleted.

1 change: 1 addition & 0 deletions docs/api/pulp_smash.config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
Location: :doc:`/index` → :doc:`/api` → :doc:`/api/pulp_smash.config`

.. automodule:: pulp_smash.config
:private-members:
6 changes: 0 additions & 6 deletions docs/api/tests.test_config_mixins.rst

This file was deleted.

4 changes: 2 additions & 2 deletions pulp_smash/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def main():
"""Provide usage instructions to the user."""
cfg_path = join(
# pylint:disable=protected-access
BaseDirectory.save_config_path(ServerConfig._xdg_config_dir),
ServerConfig._xdg_config_file
BaseDirectory.save_config_path(ServerConfig()._xdg_config_dir),
ServerConfig()._xdg_config_file
)
wrapper = textwrap.TextWrapper()
message = ''
Expand Down
307 changes: 307 additions & 0 deletions pulp_smash/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
# coding=utf-8
"""Tools for managing information about test systems.
Pulp Smash needs to know what servers it can talk to and how to talk to those
systems. For example, it needs to know the protocol, hostname and port of a
Pulp server (e.g. 'https://example.com:250') and how to authenticate with that
server. :class:`pulp_smash.config.ServerConfig` eases the task of managing that
information.
"""
from __future__ import unicode_literals

import json
from os.path import isfile, join
from threading import Lock
from xdg import BaseDirectory


# `get_config` uses this as a cache. It is intentionally a global. This design
# lets us do interesting things like flush the cache at run time or completely
# avoid a config file by fetching values from the UI.
_CONFIG = None

# Publc ServerConfig attributes.
_PUBLIC_ATTRS = ('base_url', 'auth', 'verify')


def get_config():
"""Return a copy of the global ``ServerConfig`` object.
This method makes use of a cache. If the cache is empty, the configuration
file is parsed and the cache is populated. Otherwise, a copy of the cached
configuration object is returned.
:returns: A copy of the global server configuration object.
:rtype: pulp_smash.config.ServerConfig
"""
global _CONFIG # pylint:disable=global-statement
if _CONFIG is None:
_CONFIG = ServerConfig().read()
return ServerConfig(
**{attr: getattr(_CONFIG, attr) for attr in _PUBLIC_ATTRS}
)


class ConfigFileNotFoundError(Exception):
"""Indicates that the requested XDG configuration file cannot be found."""


class ServerConfig(object):
"""Facts about a server, plus methods for manipulating those facts.
This object stores a set of facts that are used when communicating with a
Pulp server. A typical usage of this object is as follows:
>>> import requests
>>> from pulp_smash.config import ServerConfig
>>> cfg = ServerConfig(
... base_url='https://pulp.example.com',
... auth=('username', 'password'),
... verify=False, # Disable SSL verification
... )
>>> response = requests.post(
... cfg.base_url + '/pulp/api/v2/actions/login/',
... **cfg.get_requests_kwargs()
... )
One can also :meth:`save` a ``ServerConfig`` out to a file, :meth:`read`
one back and more. By way of example, assume that a file with the following
contents exists on the filesystem::
{
"default": {"base_url": "example.com", "auth": ["alice", "hackme"]},
"alternate": {"base_url": "example.org", "auth": ["bob", "hackme"]},
}
The two top-level sections can be read like so:
>>> from pulp_smash.config import ServerConfig
>>> 'example.com' == ServerConfig().read('default').base_url
>>> 'example.org' == ServerConfig().read('alternate').base_url
By default, :meth:`read` reads the "default" section. As a result, this
holds true:
>>> from pulp_smash.config import ServerConfig
>>> ServerConfig().read() == ServerConfig().read('default')
>>> ServerConfig().read() != ServerConfig().read('alternate')
All methods dealing with files obey the `XDG Base Directory Specification
<http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html>`_.
Please read the specification for insight into the logic of those methods.
:param base_url: A string. A protocol, hostname and optionally a port. For
example, ``'http://example.com:250'``. Do not append a trailing slash.
:param auth: A two-tuple. Credentials to use when communicating with the
server. For example: ``('username', 'password')``.
:param verify: A boolean. Should SSL be verified when communicating with
the server?
"""
# Used to lock access to the configuration file when performing destructive
# operations, such as saving.
_file_lock = Lock()

def __init__(self, base_url=None, auth=None, verify=None):
self.base_url = base_url
self.auth = auth
self.verify = verify

self._section = 'default'
self._xdg_config_file = 'settings.json'
self._xdg_config_dir = 'pulp_smash'

def __repr__(self):
attrs = {attr: getattr(self, attr) for attr in _PUBLIC_ATTRS}
return '{}({})'.format(
type(self).__name__,
', '.join(
'{0}={1}'.format(key, repr(value))
for key, value in attrs.items()
)
)

def save(self, section=None, xdg_config_file=None, xdg_config_dir=None):
"""Save ``self`` as a top-level section of a configuration file.
This method is thread-safe.
:param section: A string. An identifier for the current configuration.
If no top-level section named ``section`` exists in the
configuration file, one is created. Otherwise, it is replaced.
:param xdg_config_file: A string. The name of the file to manipulate.
:param xdg_config_dir: A string. The XDG configuration directory in
which the configuration file resides.
:returns: Nothing.
"""
# What will we write out?
if section is None:
section = self._section
attrs = {attr: getattr(self, attr) for attr in _PUBLIC_ATTRS}

# What file is being manipulated?
if xdg_config_file is None:
xdg_config_file = self._xdg_config_file
if xdg_config_dir is None:
xdg_config_dir = self._xdg_config_dir
path = join(
BaseDirectory.save_config_path(xdg_config_dir),
xdg_config_file
)

# Lock, write, unlock.
self._file_lock.acquire()
try:
try:
with open(path) as config_file:
config = json.load(config_file)
except IOError:
config = {}
config[section] = attrs
with open(path, 'w') as config_file:
json.dump(config, config_file)
finally:
self._file_lock.release()

def delete(self, section=None, xdg_config_file=None, xdg_config_dir=None):
"""Delete a top-level section from a configuration file.
This method is thread safe.
:param section: A string. The name of the section to be deleted.
:param xdg_config_file: A string. The name of the file to manipulate.
:param xdg_config_dir: A string. The XDG configuration directory in
which the configuration file resides.
:returns: Nothing.
"""
# What will we delete?
if section is None:
section = self._section

# What file is being manipulated?
if xdg_config_file is None:
xdg_config_file = self._xdg_config_file
if xdg_config_dir is None:
xdg_config_dir = self._xdg_config_dir
path = _get_config_file_path(xdg_config_dir, xdg_config_file)

# Lock, delete, unlock.
self._file_lock.acquire()
try:
with open(path) as config_file:
config = json.load(config_file)
del config[section]
with open(path, 'w') as config_file:
json.dump(config, config_file)
finally:
self._file_lock.release()

def sections(self, xdg_config_file=None, xdg_config_dir=None):
"""Read a configuration file and return its top-level sections.
:param xdg_config_file: A string. The name of the file to manipulate.
:param xdg_config_dir: A string. The XDG configuration directory in
which the configuration file resides.
:returns: An iterable of strings. Each string is the name of a
configuration file section.
"""
# What file is being manipulated?
if xdg_config_file is None:
xdg_config_file = self._xdg_config_file
if xdg_config_dir is None:
xdg_config_dir = self._xdg_config_dir
path = _get_config_file_path(xdg_config_dir, xdg_config_file)

with open(path) as config_file:
# keys() returns a list in Python 2 and a view in Python 3.
return set(json.load(config_file).keys())

def read(self, section=None, xdg_config_file=None, xdg_config_dir=None):
"""Read a section from a configuration file.
:param section: A string. The name of the section to be deleted.
:param xdg_config_file: A string. The name of the file to manipulate.
:param xdg_config_dir: A string. The XDG configuration directory in
which the configuration file resides.
:returns: A new :class:`pulp_smash.config.ServerConfig` object. The
current object is not modified by this method.
:rtype: ServerConfig
"""
# What section is being read?
if section is None:
section = self._section

# What file is being manipulated?
if xdg_config_file is None:
xdg_config_file = self._xdg_config_file
if xdg_config_dir is None:
xdg_config_dir = self._xdg_config_dir
path = _get_config_file_path(xdg_config_dir, xdg_config_file)

with open(path) as config_file:
return type(self)(**json.load(config_file)[section])

def get_requests_kwargs(self):
"""Get kwargs for use by the Requests functions.
This method returns a dict of attributes that can be unpacked and used
as kwargs via the ``**`` operator. For example:
>>> cfg = ServerConfig().read()
>>> requests.get(cfg.base_url + '…', **cfg.get_requests_kwargs())
This method is useful because client code may not know which attributes
should be passed from a ``ServerConfig`` object to Requests. Consider
that the example above could also be written like this:
>>> cfg = ServerConfig().get()
>>> requests.get(
... cfg.base_url + '…',
... auth=tuple(cfg.auth),
... verify=cfg.verify
... )
But this latter approach is more fragile. The user must remember to
convert ``auth`` to a tuple, and it will require maintenance if ``cfg``
gains or loses attributes.
"""
attrs = {attr: getattr(self, attr) for attr in _PUBLIC_ATTRS}
del attrs['base_url']
if attrs['auth'] is not None:
attrs['auth'] = tuple(attrs['auth'])
return attrs


def _get_config_file_path(xdg_config_dir, xdg_config_file):
"""Search ``XDG_CONFIG_DIRS`` for a config file and return the first found.
Search each of the standard XDG configuration directories for a
configuration file. Return as soon as a configuration file is found. Beware
that by the time client code attempts to open the file, it may be gone or
otherwise inaccessible.
:param xdg_config_dir: A string. The name of the directory that is suffixed
to the end of each of the ``XDG_CONFIG_DIRS`` paths.
:param xdg_config_file: A string. The name of the configuration file that
is being searched for.
:returns: A string. A path to a configuration file.
:raises pulp_smash.config.ConfigFileNotFoundError: If the requested
configuration file cannot be found.
"""
for config_dir in BaseDirectory.load_config_paths(xdg_config_dir):
path = join(config_dir, xdg_config_file)
if isfile(path):
return path
raise ConfigFileNotFoundError(
'No configuration files could be located after searching for a file '
'named "{0}" in the standard XDG configuration paths, such as '
'"~/.config/{1}/".'.format(xdg_config_file, xdg_config_dir)
)
Loading

0 comments on commit 8f7dea3

Please sign in to comment.