Skip to content

Commit

Permalink
Merge branch 'release/1.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
lig committed Mar 7, 2017
2 parents b727bf3 + 0d4d3ff commit e85bfb1
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 40 deletions.
52 changes: 52 additions & 0 deletions README.md
@@ -0,0 +1,52 @@
# Pyventory
Ansible Inventory implementation that uses Python syntax

## Install

```shell
pip install pyventory
```

## Featur e

* Modular inventor y.
* Assests inheritance using Python classe s.
* Support for multiple inheritance .
* Support for mixins .
* Support for vars templating using [Python string formatting](https://docs.python.org/3/library/string.html#format-specification-mini-language) .
* Python 3 support
* Python 2 (2.7) support


## Usage

Create `hosts.py` and make it executable.

A short example of the `hosts.py` contents:

```python
#!/usr/bin/env python
from pyventory import export_inventory

class All(Asset):
run_tests = False
use_redis = False
redis_host = 'localhost'
minify = False
version = 'develop'

class Staging(All):
run_tests = True

staging = Staging()

export_inventory(locals())
```

Consider a [more complex example](tests/e2e/example) which passes the following [json output](tests/e2e/example.json) to Ansible.

Run Ansible playbook with the `-i hosts.py` key:

```shell
ansible-playbook -i hosts.py site.yml
```
67 changes: 52 additions & 15 deletions pyventory/inventory.py
@@ -1,20 +1,22 @@
import json
import string
import sys

import attr
import six


__all__ = ['InventoryItem', 'export_inventory']
__all__ = ['Asset', 'export_inventory']


@attr.s
class GroupData:
class GroupData(object):
hosts = attr.ib(default=attr.Factory(set))
vars = attr.ib(default=attr.Factory(dict))
children = attr.ib(default=attr.Factory(set))


class Inventory:
class Inventory(object):
group_registry = {}

def __init__(self, hosts):
Expand All @@ -28,7 +30,11 @@ def add_host(self, name, host):
self.hosts[name] = host._host_vars

for group in host.__class__.__bases__:
group_name = group.__qualname__
group_name = group.__name__

# skip mixins
if group_name not in self.group_registry:
continue

if group_name not in self.groups:
self.add_group(group_name)
Expand All @@ -52,6 +58,7 @@ def export(self, sort=False):
group['children'] = list(
set(group['children']).intersection(data.keys()))
if sort:
group['hosts'].sort()
group['children'].sort()
for attr_name in ('hosts', 'vars', 'children',):
if not group[attr_name]:
Expand All @@ -62,12 +69,12 @@ def export(self, sort=False):

@classmethod
def register_group(cls, item):
cls.group_registry[item.__qualname__] = GroupData(
cls.group_registry[item.__name__] = GroupData(
vars=item._group_vars())

@classmethod
def register_child(cls, item, parent):
cls.group_registry[parent.__qualname__].children.add(item.__qualname__)
cls.group_registry[parent.__name__].children.add(item.__name__)

@classmethod
def _get_parent_names(cls, name):
Expand All @@ -80,30 +87,42 @@ def _get_parent_names(cls, name):
return parent_names


class InventoryItemMeta(type):
class AssetMeta(type):

def __new__(cls, name, bases, attrs):
item = super().__new__(cls, name, bases, attrs)
item = super(AssetMeta, cls).__new__(cls, name, bases, attrs)
if not bases:
return item

Inventory.register_group(item)

for base in bases:
if not issubclass(base, InventoryItem):
if not issubclass(base, Asset):
continue
if base is InventoryItem:
if base is Asset:
continue
Inventory.register_child(item, base)

return item


class InventoryItem(metaclass=InventoryItemMeta):
__template_vars = None # typing: list
class Asset(six.with_metaclass(AssetMeta)):

def __init__(self, **kwargs):
var_data = self._group_vars()

for template_name, template_vars in self._template_map().items():
try:
template_data = {var: kwargs.pop(var) for var in template_vars}
except KeyError:
raise ValueError(
'Not enough arguments for template `%s`: "%s"',
template_name,
var_data[template_name])

var_data[template_name] = var_data[template_name].format(
**template_data)

var_data.update(kwargs)
self._host_vars = var_data

Expand All @@ -114,14 +133,32 @@ def _group_vars(cls):
for name, value in vars(cls).items()
if not name.startswith('_')}

@classmethod
def _template_map(cls):
formatter = string.Formatter()
template_map = {}

for name, value in cls._group_vars().items():
template_vars = [
chunk[1]
for chunk in formatter.parse(value)
if chunk[1] is not None]

if not template_vars:
continue

template_map[name] = template_vars

return template_map


def export_inventory(hosts, indent=None, sort=True):
def export_inventory(hosts, out=sys.stdout, indent=None, sort=True):
inventory = Inventory({
name: obj
for name, obj in hosts.items()
if isinstance(obj, InventoryItem)})
if isinstance(obj, Asset)})
json.dump(
inventory.export(sort=sort),
sys.stdout,
out,
indent=indent,
sort_keys=sort)
22 changes: 17 additions & 5 deletions setup.py
@@ -1,6 +1,13 @@
import sys

from setuptools import setup, find_packages


install_requires = ['six', 'attrs']

if sys.version_info < (3, 4):
install_requires.append('pathlib')

setup(
name='Pyventory',
use_scm_version=True,
Expand All @@ -10,19 +17,24 @@
author_email='s@matveenko.ru',
license='BSD',
classifiers=[
'Development Status :: 2 - Pre-Alpha',
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: BSD License',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: System :: Systems Administration',
],
keywords='devops ansible inventory',
packages=find_packages(),
python_requires='>=3.3',
setup_requires=['setuptools_scm', 'pytest-runner'],
install_requires=['attrs'],
tests_require=['pytest'],
install_requires=install_requires,
extras_require={
'test': ['pytest', 'tox'],
},
entry_points={
'console_scripts': [
'pyventory=pyventory.cli:main',
Expand Down
52 changes: 49 additions & 3 deletions tests/e2e/example.json
Expand Up @@ -3,6 +3,7 @@
"children": [
"BackEnd",
"FrontEnd",
"Production",
"Staging"
],
"vars": {
Expand All @@ -15,20 +16,43 @@
},
"BackEnd": {
"hosts": [
"develop"
"develop",
"develop_sidebranch",
"prod_backend1",
"prod_backend2",
"staging"
],
"vars": {
"use_redis": true
}
},
"FrontEnd": {
"hosts": [
"develop"
"develop",
"develop_sidebranch",
"prod_frontend1",
"prod_frontend2",
"staging"
]
},
"Production": {
"hosts": [
"prod_backend1",
"prod_backend2",
"prod_frontend1",
"prod_frontend2"
],
"vars": {
"minify": true,
"use_redis": true,
"version": "master"
}
},
"Staging": {
"hosts": [
"develop"
"develop",
"develop_sidebranch",
"staging"
],
"vars": {
"run_tests": true
Expand All @@ -39,6 +63,28 @@
"develop": {
"ansible_host": "develop_hostname",
"version": "develop"
},
"develop_sidebranch": {
"ansible_host": "sidebranch_hostname",
"version": "sidebranch_name"
},
"prod_backend1": {
"ansible_hostname": "app001.prod.dom",
"redis_host": "prod_redis_hostname"
},
"prod_backend2": {
"ansible_hostname": "app002.prod.dom",
"redis_host": "prod_redis_hostname"
},
"prod_frontend1": {
"ansible_hostname": "www001.prod.dom"
},
"prod_frontend2": {
"ansible_hostname": "www002.prod.dom"
},
"staging": {
"ansible_host": "master_hostname",
"version": "master"
}
}
}
Expand Down
22 changes: 11 additions & 11 deletions tests/e2e/example/hosts.py
@@ -1,19 +1,19 @@
#!/usr/bin/env python3
#!/usr/bin/env python
from pyventory import export_inventory

from pyvars import *


develop = DevelopHost()
# develop_sidebranch = DevelopHost(
# ansible_host='sidebranch_hostname',
# version='sidebranch_name')
#
# staging = StagingHost()
#
# prod_backend1 = ProdBackEnd(num=1)
# prod_backend2 = ProdBackEnd(num=2)
# prod_frontend1 = ProdFrontEnd(num=1)
# prod_frontend2 = ProdFrontEnd(num=2)
develop_sidebranch = DevelopHost(
ansible_host='sidebranch_hostname',
version='sidebranch_name')

staging = StagingHost()

prod_backend1 = ProdBackEnd(num=1)
prod_backend2 = ProdBackEnd(num=2)
prod_frontend1 = ProdFrontEnd(num=1)
prod_frontend2 = ProdFrontEnd(num=2)

export_inventory(locals(), indent=4)
4 changes: 2 additions & 2 deletions tests/e2e/example/pyvars/base_groups.py
@@ -1,7 +1,7 @@
from pyventory import InventoryItem
from pyventory import Asset


class All(InventoryItem):
class All(Asset):
run_tests = False
use_redis = False
redis_host = 'localhost'
Expand Down
10 changes: 6 additions & 4 deletions tests/e2e/test_example.py
Expand Up @@ -7,19 +7,21 @@

@pytest.fixture(scope='session')
def example_inventory(tests_dir):
return open(str(tests_dir.joinpath('e2e', 'example.json')), 'rb').read()
return open(str(tests_dir.joinpath('e2e', 'example.json')), 'r').read()


def test_example_inventory(tests_dir, example_inventory):
project_dir = tests_dir.parent
example_dir = tests_dir.joinpath('e2e', 'example')
inventory_exe = example_dir.joinpath('hosts.py')

result = subprocess.run(
result = subprocess.check_output(
shlex.split(str(inventory_exe)),
stdout=subprocess.PIPE,
env=dict(
os.environ,
PYTHONPATH='{}:{}'.format(project_dir, example_dir)))

assert result.stdout == example_inventory
# hack for py27 `json.dump()` behavior
result = '\n'.join([x.rstrip() for x in result.decode().split('\n')])

assert result == example_inventory

0 comments on commit e85bfb1

Please sign in to comment.