Skip to content

Commit

Permalink
Merge pull request #8 from buhman/section-inheritance
Browse files Browse the repository at this point in the history
section inheritance
  • Loading branch information
thrawn01 committed Jul 12, 2016
2 parents d31a559 + 2f1f952 commit 270324e
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 23 deletions.
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ To support this, you can create a ``.hubblerc`` file in the local directory. Hub
read this file during invocation and override any global configuration with the local one.

In addition you can set a *default* environment when none is found on the command line. To do this,
you must define ``default-env`` in the ``[hubble]`` section of the config.
you must define ``default-env`` in the ``[hubble]`` section of the config.

For example, create a file called ``.hubblerc`` in the directory called ``~/dev``
```
Expand Down Expand Up @@ -317,7 +317,7 @@ script. You can find an example of what this script might look like in the


## How about running a command across multiple environments?
You can define a section in ```~/.hubblerc``` as a meta section.
You can define a section in ```~/.hubblerc``` as a meta section.
The meta section tells hubble to source all the environment variables in the current
section, then source and run the command for each section listed in the meta list.

Expand Down Expand Up @@ -350,6 +350,54 @@ a cinder command in lon, ord and dfw environments
$ hubble cinder-all list
```

## What if multiple environments share some options, but not others?
Use section inheritance.

Example
```
[hubble]
OS_AUTH_URL=http://auth.thrawn01.org
OS_USERNAME=global-user
[preprod]
OS_USERNAME=preprod-user
[preprod-region1]
%inherit=preprod
OS_REGION_NAME=region1
[preprod-reigon2]
%inherit=preprod
OS_REGION_NAME=region2
```

This demonstrates nested inheritance; the `preprod-region2` section
will have options from `preprod` and the global `hubble` section.

### Multiple inheritance

Multiple inheritance is also supported; a single `%inherit` option
with multiple newline-separated values should be used if
desired. Option values are resolved in the order they are declared,
top to bottom, the values of earlier sections taking precedent over
later sections.

Example
```
[parent1]
spam = eggs
[parent2]
spam = bacon
[child]
%inherit =
parent1
parent2
```

The value of `spam` in the `child` section is `eggs`.

## How about running an arbitrary command?
When executing remote ssh commands with tools like fabric or dsh the local user
environment doesn't get sourced which makes running custom scripts that make
Expand Down Expand Up @@ -418,7 +466,7 @@ export PATH="~/bin;$PATH"
ln -s /usr/bin/hubble ~/bin/nova
ln -s /usr/bin/hubble ~/bin/swiftly
```
When hubble is executed, it will inspect the name it was invoked as (in
When hubble is executed, it will inspect the name it was invoked as (in
this case the linked name) and attempt to execute *that* name as the command.
If executables for nova and swiftly are installed in ``/usr/bin``; Your done!

Expand Down Expand Up @@ -474,4 +522,3 @@ $ supernova prod list
* **${opt.option}** - The argument passed in via the -o|--option command line argument
* **${opt.env}** - The environment name passed in as a command line argument
* **${opt.debug}** - 'True' if --debug was used on the command line else 'False'

75 changes: 67 additions & 8 deletions hubble/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,77 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from six.moves.configparser import NoSectionError, NoOptionError
from six.moves.configparser import RawConfigParser
from six import string_types
from configparser import NoSectionError, NoOptionError
from configparser import RawConfigParser, _UNSET
from itertools import chain
import os

class SafeConfigParser(RawConfigParser):

class ListConfigParser(RawConfigParser):
def __init__(self, *args, **kwargs):
super(ListConfigParser, self).__init__(*args, **kwargs)

self._converters.update(
list=self.list_converter
)

@staticmethod
def list_converter(value):
if isinstance(value, string_types):
value = filter(None, (i.strip() for i in value.splitlines()))
return list(value)


class InheritanceConfigParser(ListConfigParser):
def _supersections(self, section):
try:
section_names = self.list_converter(self._sections[section]['%inherit'])
except KeyError:
return []

sections = [self._sections[section] for section in section_names]

# nested inheritance
hypersections = (self._supersections(section_name) for section_name in section_names)

return chain(sections, *hypersections)


def _unify_values(self, section, vars):
'''Inject supersections into the correct position in the inheritance
chain.
'''
chain = super(InheritanceConfigParser, self)._unify_values(section, vars)

chain.maps[2:2] = self._supersections(section)

return chain

def items(self, section=_UNSET, raw=False, vars=None):
'''This is actually an upstream bug, imo.
'''

d = self._unify_values(section, vars)

value_getter = lambda option: self._interpolation.before_get(self,
section, option, d[option], d)
if raw:
value_getter = lambda option: d[option]

return [(self.optionxform(option), value_getter(option)) for option in d.keys()]


class SafeConfigParser(InheritanceConfigParser):
""" Simple subclass to add the safeGet() method """
def getError(self):
return None

def safeGet(self, section, key):
try:
return RawConfigParser.get(self, section, key)
return super(SafeConfigParser, self).get(section, key)
except (NoSectionError, NoOptionError):
return None

Expand All @@ -45,20 +104,20 @@ def openFd(file):
except IOError:
return None

def readConfigs(files=None):
def readConfigs(files=None, default_section=None):
""" Given a list of file names, return a list of handles to succesfully opened files"""
files = files or [os.path.expanduser('~/.hubblerc'), '.hubblerc']
# If non of these files exist, raise an error
if not any([os.path.exists(rc) for rc in files]):
return ErrorConfigParser("Unable to find config files in these"
" locations [%s]" % ", ".join(files))
return parseConfigs([openFd(file) for file in files])
return parseConfigs([openFd(file) for file in files], default_section)


def parseConfigs(fds):
def parseConfigs(fds, default_section=None):
""" Given a list of file handles, parse all the files with ConfigParser() """
# Read the config file
config = SafeConfigParser()
config = SafeConfigParser(default_section=default_section)
# Don't transform (lowercase) the key values
config.optionxform = str
# Read all the file handles passed
Expand Down
14 changes: 4 additions & 10 deletions hubble/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from __future__ import print_function

from subprocess import check_output, CalledProcessError, Popen, PIPE
from six.moves.configparser import NoSectionError
from configparser import NoSectionError
from hubble.config import readConfigs
import argparse
import textwrap
Expand Down Expand Up @@ -94,7 +94,7 @@ def expandVar(self, variable, pair):
var = match.group(0)
key = var[2:-1]
# Replace the entire ${...} sequence with the value named
result = str.replace(result, var, self.get(key).value)
result = result.replace(var, self.get(key).value)
except AttributeError:
raise RuntimeError("no such environment variable "
"'%s' in '%s'" % (key, result))
Expand Down Expand Up @@ -148,12 +148,6 @@ def getEnvironments(args, choice, config):
results = []
conf = Env()

try:
# Get the default variables if exists
conf.add(dict(config.items('hubble')), 'hubble')
except NoSectionError:
pass

# Merge in the requested environment
conf.add(dict(config.items(choice)), choice)
# If requested section is a meta section
Expand Down Expand Up @@ -219,7 +213,7 @@ def cmdPath(cmd, conf):


def evalArgs(conf, parser):
env = conf.safeGet('hubble', 'default-env')
env = conf.safeGet(conf.default_section, 'default-env')
# If no default environment set, look for an
# environment choice on the command line
if not env:
Expand Down Expand Up @@ -256,7 +250,7 @@ def main():

try:
# Read the configs
conf = readConfigs()
conf = readConfigs(default_section='hubble')
# Evaluate the command line arguments and return our args
# the commands args and the environment choice the user made
hubble_args, other_args, choice = evalArgs(conf, parser)
Expand Down
Empty file added hubble/tests/__init__.py
Empty file.
Empty file added hubble/tests/unit/__init__.py
Empty file.
25 changes: 25 additions & 0 deletions hubble/tests/unit/fake_configs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@staticmethod
def fake_inheritance_config(config_factory):
config_string = u"""
[hubble]
spam = eggs
[a]
a = 1
[b]
b = 2
spam = bar
[c]
%inherit = a
c = 3
[d]
%inherit = c
d = 4
spam = foo
[e]
%inherit =
d
b
e = 5
"""

return config_factory(config_string)
59 changes: 59 additions & 0 deletions hubble/tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import unittest

from hubble import config
from hubble.tests.unit import fake_configs


class TestCase(unittest.TestCase):
default_section = 'hubble'

def fromstring(self, config_string):
config = self.config_class(
default_section=self.default_section
)
config.read_string(config_string)
return config

def set(self, section):
return set(dict(self.config.items(section)))

def setUp(self):
super(TestCase, self).setUp()
self.config = self.fake_config(self.fromstring)


class TestInheritance(TestCase):
config_class = config.InheritanceConfigParser
fake_config = fake_configs.fake_inheritance_config

def test_items_get(self):
items = dict(self.config.items('c'))
value = self.config.get('c', 'a')
self.assertEqual(items['a'], value)

def test_inheritance(self):
c = self.set('c')
a = self.set('a')
self.assertTrue(c.issuperset(a))

def test_nested_inheritance(self):
d = self.set('d')
a = self.set('a')
self.assertTrue(d.issuperset(a))

def test_multiple_inheritance(self):
e = self.set('e')
b = self.set('b')
d = self.set('d')
self.assertTrue(e.issuperset(b))
self.assertTrue(e.issuperset(d))

def test_value_resolution(self):
b_spam = self.config.get('b', 'spam')
self.assertEquals(b_spam, 'bar')
c_spam = self.config.get('c', 'spam')
self.assertEquals(c_spam, 'eggs')
d_spam = self.config.get('d', 'spam')
self.assertEquals(d_spam, 'foo')
e_spam = self.config.get('e', 'spam')
self.assertEquals(e_spam, 'foo')
3 changes: 2 additions & 1 deletion tests/test_hubble.py → hubble/tests/unit/test_hubble.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def test_getEnvironments(self):
"FIRST=Derrick\n"
"last=Wippler\n")
file.name = "test-config.ini"
env = getEnvironments(args, 'name', parseConfigs([file]))
config = parseConfigs([file], default_section='hubble')
env = getEnvironments(args, 'name', config)
self.assertIn('name', env[0])
self.assertEqual(env[0]['name'].value, 'My name is Derrick Wippler')

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
keyring>=5.0
six>=1.10.0
configparser>=3.5.0

0 comments on commit 270324e

Please sign in to comment.