summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore21
-rw-r--r--Makefile86
-rw-r--r--README.rst140
l---------debian/changelog.in1
-rw-r--r--debian/compat1
-rw-r--r--debian/control14
l---------debian/copyright1
-rw-r--r--debian/postinst2
-rw-r--r--debian/prerm3
-rw-r--r--debian/pyversions1
-rwxr-xr-xdebian/rules11
-rw-r--r--debian/watch1
-rw-r--r--dist/reconfigure.spec.in37
-rw-r--r--docs/CHANGELOG5
-rw-r--r--docs/COPYRIGHT5
-rw-r--r--docs/LICENSE165
-rw-r--r--docs/Makefile130
-rw-r--r--docs/source/conf.py51
-rw-r--r--docs/source/docs/architecture.rst10
-rw-r--r--docs/source/docs/architecture/bound.rst131
-rw-r--r--docs/source/docs/architecture/components.rst90
-rw-r--r--docs/source/docs/architecture/config.rst43
-rw-r--r--docs/source/docs/architecture/trees.rst197
-rw-r--r--docs/source/docs/quickstart.rst96
-rw-r--r--docs/source/index.rst46
-rw-r--r--docs/source/ref/reconfigure.builders.rst6
-rw-r--r--docs/source/ref/reconfigure.configs.rst8
-rw-r--r--docs/source/ref/reconfigure.includers.rst6
-rw-r--r--docs/source/ref/reconfigure.items.bound.rst6
-rw-r--r--docs/source/ref/reconfigure.nodes.rst6
-rw-r--r--docs/source/ref/reconfigure.parsers.rst6
-rw-r--r--reconfigure.sublime-project8
-rw-r--r--reconfigure/__init__.py1
-rw-r--r--reconfigure/builders/__init__.py16
-rw-r--r--reconfigure/builders/base.py16
-rw-r--r--reconfigure/builders/bound.py18
-rw-r--r--reconfigure/builders/bound_tests.py52
-rw-r--r--reconfigure/configs/__init__.py46
-rw-r--r--reconfigure/configs/ajenti.py14
-rw-r--r--reconfigure/configs/base.py71
-rw-r--r--reconfigure/configs/bind9.py19
-rw-r--r--reconfigure/configs/crontab.py14
-rw-r--r--reconfigure/configs/ctdb.py44
-rw-r--r--reconfigure/configs/dhcpd.py17
-rw-r--r--reconfigure/configs/exports.py17
-rw-r--r--reconfigure/configs/fstab.py17
-rw-r--r--reconfigure/configs/group.py18
-rw-r--r--reconfigure/configs/hosts.py17
-rw-r--r--reconfigure/configs/iptables.py17
-rw-r--r--reconfigure/configs/netatalk.py18
-rw-r--r--reconfigure/configs/nsd.py17
-rw-r--r--reconfigure/configs/passwd.py18
-rw-r--r--reconfigure/configs/resolv.py18
-rw-r--r--reconfigure/configs/samba.py14
-rw-r--r--reconfigure/configs/squid.py14
-rw-r--r--reconfigure/configs/supervisor.py20
-rw-r--r--reconfigure/includers/__init__.py14
-rw-r--r--reconfigure/includers/auto.py68
-rw-r--r--reconfigure/includers/base.py21
-rw-r--r--reconfigure/includers/bind9.py11
-rw-r--r--reconfigure/includers/nginx.py11
-rw-r--r--reconfigure/includers/supervisor.py11
-rw-r--r--reconfigure/items/__init__.py1
-rw-r--r--reconfigure/items/ajenti.py54
-rw-r--r--reconfigure/items/bind9.py25
-rw-r--r--reconfigure/items/bound.py302
-rw-r--r--reconfigure/items/crontab.py64
-rw-r--r--reconfigure/items/ctdb.py50
-rw-r--r--reconfigure/items/dhcpd.py35
-rw-r--r--reconfigure/items/exports.py30
-rw-r--r--reconfigure/items/fstab.py26
-rw-r--r--reconfigure/items/group.py15
-rw-r--r--reconfigure/items/hosts.py26
-rw-r--r--reconfigure/items/iptables.py120
-rw-r--r--reconfigure/items/netatalk.py38
-rw-r--r--reconfigure/items/nsd.py23
-rw-r--r--reconfigure/items/passwd.py15
-rw-r--r--reconfigure/items/resolv.py19
-rw-r--r--reconfigure/items/samba.py59
-rw-r--r--reconfigure/items/squid.py95
-rw-r--r--reconfigure/items/supervisor.py22
-rw-r--r--reconfigure/items/util.py3
-rw-r--r--reconfigure/nodes.py184
-rw-r--r--reconfigure/parsers/__init__.py25
-rw-r--r--reconfigure/parsers/base.py18
-rw-r--r--reconfigure/parsers/bind9.py20
-rw-r--r--reconfigure/parsers/crontab.py82
-rw-r--r--reconfigure/parsers/exports.py47
-rw-r--r--reconfigure/parsers/ini.py70
-rw-r--r--reconfigure/parsers/iniparse/__init__.py25
-rw-r--r--reconfigure/parsers/iniparse/compat.py343
-rw-r--r--reconfigure/parsers/iniparse/config.py293
-rw-r--r--reconfigure/parsers/iniparse/ini.py642
-rw-r--r--reconfigure/parsers/iniparse/utils.py47
-rw-r--r--reconfigure/parsers/iptables.py72
-rw-r--r--reconfigure/parsers/jsonparser.py35
-rw-r--r--reconfigure/parsers/nginx.py87
-rw-r--r--reconfigure/parsers/nsd.py50
-rw-r--r--reconfigure/parsers/squid.py53
-rw-r--r--reconfigure/parsers/ssv.py72
-rw-r--r--reconfigure/tests/__init__.py0
-rw-r--r--reconfigure/tests/configs/__init__.py0
-rw-r--r--reconfigure/tests/configs/ajenti_tests.py52
-rw-r--r--reconfigure/tests/configs/base_test.py37
-rw-r--r--reconfigure/tests/configs/bind9_tests.py28
-rw-r--r--reconfigure/tests/configs/crontab_tests.py53
-rw-r--r--reconfigure/tests/configs/ctdb_tests.py73
-rw-r--r--reconfigure/tests/configs/dhcpd_tests.py50
-rw-r--r--reconfigure/tests/configs/exports_tests.py45
-rw-r--r--reconfigure/tests/configs/fstab_tests.py34
-rw-r--r--reconfigure/tests/configs/group_tests.py30
-rw-r--r--reconfigure/tests/configs/hosts_tests.py40
-rw-r--r--reconfigure/tests/configs/iptables_tests.py100
-rw-r--r--reconfigure/tests/configs/netatalk_tests.py41
-rw-r--r--reconfigure/tests/configs/nsd_tests.py26
-rw-r--r--reconfigure/tests/configs/passwd_tests.py36
-rw-r--r--reconfigure/tests/configs/resolv_tests.py31
-rw-r--r--reconfigure/tests/configs/samba_tests.py70
-rw-r--r--reconfigure/tests/configs/squid_tests.py63
-rw-r--r--reconfigure/tests/configs/supervisor_tests.py36
-rw-r--r--reconfigure/tests/includers/__init__.py0
-rw-r--r--reconfigure/tests/includers/nginx_tests.py29
-rw-r--r--reconfigure/tests/parsers/__init__.py0
-rw-r--r--reconfigure/tests/parsers/base_test.py30
-rw-r--r--reconfigure/tests/parsers/bind9_tests.py55
-rw-r--r--reconfigure/tests/parsers/crontab_tests.py57
-rw-r--r--reconfigure/tests/parsers/exports_tests.py50
-rw-r--r--reconfigure/tests/parsers/ini_tests.py26
-rw-r--r--reconfigure/tests/parsers/iptables_tests.py61
-rw-r--r--reconfigure/tests/parsers/jsonparser_tests.py24
-rw-r--r--reconfigure/tests/parsers/nginx_tests.py38
-rw-r--r--reconfigure/tests/parsers/nsd_tests.py29
-rw-r--r--reconfigure/tests/parsers/squid_tests.py29
-rw-r--r--reconfigure/tests/parsers/ssv_tests.py39
-rw-r--r--requirements.txt0
-rw-r--r--setup.py19
136 files changed, 6521 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f1f2a9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+*.pyc
+*.pyo
+.DS_Store
+*~
+*.sublime-workspace
+.coverage
+
+debian/python-reconfigure*
+debian/changelog
+dist/*.rpm
+dist/*.deb
+dist/*.gz
+*.egg*
+build/*
+debian/files
+debian/*stamp*
+
+docs/build
+
+nosetests*
+.idea
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3575e77
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,86 @@
+PYTHON=`which python`
+DESTDIR=/
+BUILDIR=$(CURDIR)/debian/reconfigure
+RPMTOPDIR=$(CURDIR)/build
+PROJECT=reconfigure
+DEBPROJECT=python-reconfigure
+VERSION=`python -c "from reconfigure import __version__; print __version__"`
+PREFIX=/usr
+
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+DOCBUILDDIR = docs/build
+DOCSOURCEDIR = docs/source
+ALLSPHINXOPTS = -d $(DOCBUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) $(DOCSOURCEDIR)
+
+all:
+
+build:
+
+doc:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(DOCBUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+cdoc:
+ rm -rf $(DOCBUILDDIR)/*
+ make doc
+
+install:
+ $(PYTHON) setup.py install --root $(DESTDIR) $(COMPILE) --prefix $(PREFIX)
+
+rpm: build tgz
+ rm -rf dist/*.rpm
+
+ cat dist/$(PROJECT).spec.in | sed s/__VERSION__/$(VERSION)/g > $(PROJECT).spec
+
+ mkdir -p build/SOURCES || true
+ cp dist/$(PROJECT)*.tar.gz build/SOURCES
+
+ rpmbuild --define '_topdir $(RPMTOPDIR)' -bb $(PROJECT).spec
+
+ mv build/RPMS/noarch/$(PROJECT)*.rpm dist
+
+ rm $(PROJECT).spec
+
+deb: build tgz
+ rm -rf dist/*.deb
+
+ cat debian/changelog.in | sed s/__VERSION__/$(VERSION)/g | sed "s/__DATE__/$(DATE)/g" > debian/changelog
+
+ cp dist/$(PROJECT)*.tar.gz ..
+ rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(DEBPROJECT)_$$1\.orig\.tar\.gz/' ../*
+ dpkg-buildpackage -b -rfakeroot -us -uc
+
+ mv ../$(DEBPROJECT)*.deb dist/
+
+ rm ../$(DEBPROJECT)*.orig.tar.gz
+ rm ../$(DEBPROJECT)*.changes
+ rm debian/changelog
+
+upload-deb: deb
+ scp dist/*.deb root@ajenti.org:/srv/repo/ng/debian
+ ssh root@ajenti.org /srv/repo/rebuild-debian.sh
+
+upload-rpm: rpm
+ scp dist/*.rpm root@ajenti.org:/srv/repo/ng/centos/6
+ ssh root@ajenti.org /srv/repo/rebuild-centos.sh
+
+upload-tgz: tgz
+ $(PYTHON) setup.py sdist upload
+
+tgz: build
+ rm dist/*.tar.gz || true
+ $(PYTHON) setup.py sdist
+
+
+clean:
+ $(PYTHON) setup.py clean
+ rm -rf $(DOCBUILDDIR)/*
+ rm -rf build/ debian/$(PROJECT)* debian/*stamp* debian/files MANIFEST *.egg-info
+ find . -name '*.pyc' -delete
+
+
+test:
+ nosetests -v
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..b45af01
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,140 @@
+Reconfigure - Python object mapping for config files.
+
+Quickstart::
+
+ >>> from reconfigure.configs import FSTabConfig
+ >>> from reconfigure.items.fstab import FilesystemData
+ >>>
+ >>> config = FSTabConfig(path='/etc/fstab')
+ >>> config.load()
+ >>> print config.tree
+ {
+ "filesystems": [
+ {
+ "passno": "0",
+ "device": "proc",
+ "mountpoint": "/proc",
+ "freq": "0",
+ "type": "proc",
+ "options": "nodev,noexec,nosuid"
+ },
+ {
+ "passno": "1",
+ "device": "UUID=dfccef1e-d46c-45b8-969d-51391898c55e",
+ "mountpoint": "/",
+ "freq": "0",
+ "type": "ext4",
+ "options": "errors=remount-ro"
+ }
+ ]
+ }
+ >>> tmpfs = FilesystemData()
+ >>> tmpfs.mountpoint = '/srv/cache'
+ >>> tmpfs.type = 'tmpfs'
+ >>> tmpfs.device = 'none'
+ >>> config.tree.filesystems.append(tmpfs)
+ >>> config.save()
+ >>> quit()
+ $ cat /etc/fstab
+ proc /proc proc nodev,noexec,nosuid 0 0
+ UUID=dfccef1e-d46c-45b8-969d-51391898c55e / ext4 errors=remount-ro 0 1
+ none /srv/cache tmpfs none 0 0
+
+This is actually a shortcut to::
+
+ >>> from reconfigure.parsers import SSVParser
+ >>> from reconfigure.builders import BoundBuilder
+ >>> from reconfigure.items.fstab import FSTabData
+ >>> content = open('/etc/fstab').read()
+ >>> syntax_tree = SSVParser().parse(content)
+ >>> syntax_tree
+ <reconfigure.nodes.RootNode object at 0x7f1319eeec50>
+ >>> print syntax_tree
+ (None)
+ (line)
+ (token)
+ value = proc
+ (token)
+ value = /proc
+ (token)
+ value = proc
+ (token)
+ value = nodev,noexec,nosuid
+ (token)
+ value = 0
+ (token)
+ value = 0
+ (line)
+ (token)
+ value = UUID=83810b56-ef4b-44de-85c8-58dc589aef48
+ (token)
+ value = /
+ (token)
+ value = ext4
+ (token)
+ value = errors=remount-ro
+ (token)
+ value = 0
+ (token)
+ value = 1
+
+ >>> builder = BoundBuilder(FSTabData)
+ >>> data_tree = builder.build(syntax_tree)
+ >>> print data_tree
+ {
+ "filesystems": [
+ {
+ "passno": "0",
+ "device": "proc",
+ "mountpoint": "/proc",
+ "freq": "0",
+ "type": "proc",
+ "options": "nodev,noexec,nosuid"
+ },
+ {
+ "passno": "1",
+ "device": "UUID=83810b56-ef4b-44de-85c8-58dc589aef48",
+ "mountpoint": "/",
+ "freq": "0",
+ "type": "ext4",
+ "options": "errors=remount-ro"
+ }
+ ]
+ }
+
+Parsers and builders can be paired in almost any possible combination.
+
+Reconfigure can be easily extended with your own parsers and builders - read the docs!
+
+Supported configs:
+
+ * Ajenti (``ajenti``)
+ * BIND9 DNS (``bind9``)
+ * Crontabs (``crontab``)
+ * Samba CTDB (``ctdb``)
+ * ISC DHCPD / uDHCPD (``dhcpd``)
+ * NFS /etc/exports (``exports``)
+ * /etc/fstab (``fstab``)
+ * /etc/group (``group``)
+ * /etc/hosts (``hosts``)
+ * iptables-save dump (``iptables``)
+ * Netatalk afp.conf (``netatalk``)
+ * NSD DNS (``nsd``)
+ * /etc/passwd (``passwd``)
+ * /etc/resolv.conf (``resolv``)
+ * Samba (``samba``)
+ * Squid 3 (``squid``)
+ * Supervisord (``supervisor``)
+
+Included parsers:
+
+ * BIND9 config (``bind9``)
+ * Crontab (``crontab``)
+ * NFS Exports (``exports``)
+ * .ini (``ini``)
+ * iptables-save (``iptables``)
+ * nginx-like (``nginx``)
+ * squid (``squid``)
+ * nsd (``nsd``)
+ * CSV-like space-separated values (``ssv``)
+ * JSON (``jsonparser``) \ No newline at end of file
diff --git a/debian/changelog.in b/debian/changelog.in
new file mode 120000
index 0000000..1af8df1
--- /dev/null
+++ b/debian/changelog.in
@@ -0,0 +1 @@
+../docs/CHANGELOG \ No newline at end of file
diff --git a/debian/compat b/debian/compat
new file mode 100644
index 0000000..45a4fb7
--- /dev/null
+++ b/debian/compat
@@ -0,0 +1 @@
+8
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..f13d970
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,14 @@
+Source: python-reconfigure
+Section: python
+Priority: optional
+Maintainer: Eugeny Pankov <e@ajenti.org>
+Build-Depends: debhelper (>=8.0.0), python-support (>= 0.6), cdbs (>= 0.4.49)
+XS-Python-Version: >=2.6
+Standards-Version: 3.9.1
+
+Package: python-reconfigure
+Architecture: all
+Homepage: http://eugeny.github.com/reconfigure
+XB-Python-Version: ${python:Versions}
+Depends: ${misc:Depends}, ${python:Depends}, python-chardet
+Description: Simple config file management library
diff --git a/debian/copyright b/debian/copyright
new file mode 120000
index 0000000..7c36994
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1 @@
+../docs/COPYRIGHT \ No newline at end of file
diff --git a/debian/postinst b/debian/postinst
new file mode 100644
index 0000000..bd4ec77
--- /dev/null
+++ b/debian/postinst
@@ -0,0 +1,2 @@
+#!/bin/sh
+#DEBHELPER#
diff --git a/debian/prerm b/debian/prerm
new file mode 100644
index 0000000..c35303b
--- /dev/null
+++ b/debian/prerm
@@ -0,0 +1,3 @@
+#!/bin/sh
+#DEBHELPER#
+
diff --git a/debian/pyversions b/debian/pyversions
new file mode 100644
index 0000000..0c043f1
--- /dev/null
+++ b/debian/pyversions
@@ -0,0 +1 @@
+2.6-
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..fb55c26
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,11 @@
+#!/usr/bin/make -f
+# -*- makefile -*-
+
+DEB_PYTHON_SYSTEM := pysupport
+
+include /usr/share/cdbs/1/rules/debhelper.mk
+include /usr/share/cdbs/1/class/python-distutils.mk
+
+clean::
+ rm -rf build build-stamp configure-stamp build/ MANIFEST
+ dh_clean
diff --git a/debian/watch b/debian/watch
new file mode 100644
index 0000000..9e7c0da
--- /dev/null
+++ b/debian/watch
@@ -0,0 +1 @@
+version=3
diff --git a/dist/reconfigure.spec.in b/dist/reconfigure.spec.in
new file mode 100644
index 0000000..c84631e
--- /dev/null
+++ b/dist/reconfigure.spec.in
@@ -0,0 +1,37 @@
+%define name reconfigure
+%define version __VERSION__
+%define unmangled_version __VERSION__
+%define release 1
+
+Summary: The server administration panel
+Name: %{name}
+Version: %{version}
+Release: %{release}
+Source0: %{name}-%{version}.tar.gz
+License: LGPLv3
+Group: Development/Libraries
+BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot
+Prefix: %{_prefix}
+BuildArch: noarch
+Vendor: Eugene Pankov <e@ajenti.org>
+Url: http://ajenti.org/
+
+requires: python-chardet
+
+%description
+Easy config file management
+
+%prep
+%setup -n %{name}-%{unmangled_version} -n %{name}-%{unmangled_version}
+
+%build
+python setup.py build
+
+%install
+python setup.py install --single-version-externally-managed -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES --prefix=/usr
+
+%clean
+rm -rf $RPM_BUILD_ROOT
+
+%files -f INSTALLED_FILES
+%defattr(-,root,root)
diff --git a/docs/CHANGELOG b/docs/CHANGELOG
new file mode 100644
index 0000000..394d5e2
--- /dev/null
+++ b/docs/CHANGELOG
@@ -0,0 +1,5 @@
+python-reconfigure (__VERSION__) UNRELEASED; urgency=low
+
+ * Initial release.
+
+ -- Eugeny Pankov <e@ajenti.org> Thu, 7 Feb 2013 00:12:00 +0300
diff --git a/docs/COPYRIGHT b/docs/COPYRIGHT
new file mode 100644
index 0000000..a3ccff0
--- /dev/null
+++ b/docs/COPYRIGHT
@@ -0,0 +1,5 @@
+Copyright (c) 2012-2013 The Ajenti Team
+* Eugeny Pankov <john.pankov@gmail.com> & contributors
+
+LICENSED UNDER LGPLv3
+See LICENSE.
diff --git a/docs/LICENSE b/docs/LICENSE
new file mode 100644
index 0000000..65c5ca8
--- /dev/null
+++ b/docs/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..4b9db37
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,130 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = build
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ -rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Ajenti.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Ajenti.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/Ajenti"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Ajenti"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ make -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..1cbb69c
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+import sys
+import os
+
+sys.path.insert(0, os.path.abspath('../..'))
+
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode'] # 'sphinx.ext.intersphinx']
+
+templates_path = ['_templates']
+
+source_suffix = '.rst'
+
+#source_encoding = 'utf-8-sig'
+
+master_doc = 'index'
+
+project = u'Reconfigure'
+copyright = u'2013, Eugeny Pankov'
+
+version = '1.0'
+release = '1.0a1'
+
+exclude_patterns = []
+add_function_parentheses = True
+
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+
+# -- Options for HTML output ---------------------------------------------------
+html_theme = 'air'
+#html_theme_options = {}
+html_theme_path = ['../../../sphinx-themes']
+
+html_title = 'Reconfigure documentation'
+html_short_title = 'Reconfigure docs'
+
+#html_logo = None
+
+#html_favicon = None
+
+html_static_path = ['_static']
+
+htmlhelp_basename = 'Reconfiguredoc'
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {'http://docs.python.org/': None}
+
diff --git a/docs/source/docs/architecture.rst b/docs/source/docs/architecture.rst
new file mode 100644
index 0000000..34247ab
--- /dev/null
+++ b/docs/source/docs/architecture.rst
@@ -0,0 +1,10 @@
+Architecture
+************
+
+.. toctree::
+ :maxdepth: 2
+
+ architecture/trees
+ architecture/bound
+ architecture/components
+ architecture/config
diff --git a/docs/source/docs/architecture/bound.rst b/docs/source/docs/architecture/bound.rst
new file mode 100644
index 0000000..e59b951
--- /dev/null
+++ b/docs/source/docs/architecture/bound.rst
@@ -0,0 +1,131 @@
+.. _Bound data:
+
+Bound Data
+**********
+
+Bound data (:class:`reconfigure.items.bound.BoundData`) is a special class that can be subclassed and stuffed with properties, which will act as proxies to an underlying :ref:`Node tree<node-tree>`. This can be confusing, so let's go with an example::
+
+
+ >>> from reconfigure.nodes import Node, PropertyNode
+ >>> from reconfigure.items.bound import BoundData
+ >>>
+ >>> node = Node('test')
+ >>> node.append(PropertyNode('name', 'Alice'))
+ >>> node.append(PropertyNode('age', '25'))
+ >>> node.append(PropertyNode('gender', 'f'))
+ >>> print node
+ (test)
+ name = Alice
+ age = 25
+ gender = f
+
+Here we have a very simple :ref:`Node tree <node-tree>`.
+Note that all values are ``str`` and the ``gender`` is coded in a single character (we have probably parsed this tree from some .ini file).
+Now let's define a BoundData class::
+
+ >>> class HumanData (BoundData):
+ ... pass
+ ...
+ >>> HumanData.bind_property('name', 'name')
+ >>> HumanData.bind_property('age', 'age', getter=int, setter=str)
+ >>> HumanData.bind_property('gender', 'gender',
+ ... getter=lambda x: 'Male' if x == 'm' else 'Female',
+ ... setter=lambda x: 'm' if x == 'Male' else 'f')
+
+ >>> human = HumanData(node)
+ >>> human
+ <__main__.MyData object at 0x114ddd0>
+ >>> print human
+ {
+ "gender": "Female",
+ "age": 25,
+ "name": "Alice"
+ }
+
+First, we've defined our ``BoundData`` subclass. Then, we have defined three properties in it:
+
+ * ``name`` is the simplest property, it's directly bound to "name" child ``PropertyNode``
+ * ``age`` also has a getter and setter. These are invoked when the property is read or written. In this case, we use ``int()`` to parse a number from the node tree and ``str()`` to stringify it when writing back.
+ * ``gender`` is similar to ``age`` but has more complex getter and setter that transform "m" and "f" to a human-readable description.
+
+When the properties are mutated, the modifications are applied to Node tree immediately and vice versa::
+
+ >>> human.age
+ 25
+ >>> human.age = 30
+ >>> node.get('age').value
+ '30'
+ >>> node.get('age').value = 27
+ >>> human.age
+ 27
+
+
+Using collections
+=================
+
+Let's try a more complex node tree::
+
+ >>> nodes = Node('',
+ ... Node('Alice',
+ ... PropertyNode('Phone', '1234-56-78')
+ ... ),
+ ... Node('Bob',
+ ... PropertyNode('Phone', '8765-43-21')
+ ... )
+ ... )
+ >>> print nodes
+ ()
+ (Alice)
+ Phone = 1234-56-78
+ (Bob)
+ Phone = 8765-43-21
+
+Bound data classes::
+
+ >>> class PersonData (BoundData):
+ ... def template(self, name, phone):
+ ... return Node(name,
+ ... PropertyNode('Phone', phone)
+ ... )
+ ...
+ >>> class PhonebookData (BoundData):
+ ... pass
+ ...
+ >>> PersonData.bind_property('Phone', 'phone')
+ >>> PersonData.bind_name('name')
+ >>>
+ >>> PhonebookData.bind_collection('entries', item_class=PersonData)
+ >>>
+ >>> phonebook = PhonebookData(nodes)
+ >>> print phonebook
+ {
+ "entries": [
+ {
+ "phone": "1234-56-78",
+ "name": "Alice"
+ },
+ {
+ "phone": "8765-43-21",
+ "name": "Bob"
+ }
+ ]
+ }
+
+Here, ``bind_collection`` method is used to create a collection property from child nodes. ``item_class`` class will be used to wrap these nodes.
+
+Alternatively, you can employ :class:`reconfigure.items.bound.BoundDictionary` class to create a dict-like property::
+
+ >>> PhonebookData.bind_collection('entries', collection_class=BoundDictionary, item_class=PersonData, key=lambda x: x.name)
+ >>> print phonebook
+ {
+ "entries": {
+ "Bob": {
+ "phone": "8765-43-21",
+ "name": "Bob"
+ },
+ "Alice": {
+ "phone": "1234-56-78",
+ "name": "Alice"
+ }
+ }
+ } \ No newline at end of file
diff --git a/docs/source/docs/architecture/components.rst b/docs/source/docs/architecture/components.rst
new file mode 100644
index 0000000..072a6d0
--- /dev/null
+++ b/docs/source/docs/architecture/components.rst
@@ -0,0 +1,90 @@
+Components
+**********
+
+.. _parser:
+
+Parsers
+=======
+
+Parsers are :class:`reconfigure.parsers.BaseParser` subclasses which transform :ref:`raw config content <raw-config>` into :ref:`node trees <node-tree>` and vice versa
+
+Making your own parser is as easy as subclassing :class:`reconfigure.parsers.BaseParser` and overriding ``parse`` and ``stringify`` methods.
+
+
+.. _includer:
+
+Includers
+=========
+
+Includers are used to handle the "include" directives in config files. Includers assemble the config file by finding the included files and parsing them and attaching them to the :ref:`node tree <node-tree>` of the main config. Reconfigure keeps track of which node belongs to which file by setting ``origin`` attribute on the included nodes
+
+Example of includer in action:
+
+ >>> from reconfigure.parsers import *
+ >>> from reconfigure.includers import *
+ >>> parser = IniFileParser()
+ >>> includer = SupervisorIncluder(parser)
+ >>> nodes = parser.parse(open('/etc/supervisor/supervisord.conf').read())
+ >>> print nodes
+ (None)
+ (unix_http_server)
+ file = /var/run//supervisor.sock ((the path to the socket file))
+ chmod = 0700 (sockef file mode (default 0700))
+ (supervisord)
+ logfile = /var/log/supervisor/supervisord.log ((main log file;default $CWD/supervisord.log))
+ pidfile = /var/run/supervisord.pid ((supervisord pidfile;default supervisord.pid))
+ childlogdir = /var/log/supervisor (('AUTO' child log dir, default $TEMP))
+ (rpcinterface:supervisor)
+ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
+ (supervisorctl)
+ serverurl = unix:///var/run//supervisor.sock (use a unix:// URL for a unix socket)
+ (include)
+ files = /etc/supervisor/conf.d/*.conf
+
+Note the "include" node in the end. Now we'll run an includer over this tree::
+
+ >>> nodes = includer.compose('/etc/supervisor/supervisord.conf', nodes)
+ >>> print nodes
+ (None)
+ (unix_http_server)
+ file = /var/run//supervisor.sock ((the path to the socket file))
+ chmod = 0700 (sockef file mode (default 0700))
+ (supervisord)
+ logfile = /var/log/supervisor/supervisord.log ((main log file;default $CWD/supervisord.log))
+ pidfile = /var/run/supervisord.pid ((supervisord pidfile;default supervisord.pid))
+ childlogdir = /var/log/supervisor (('AUTO' child log dir, default $TEMP))
+ (rpcinterface:supervisor)
+ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
+ (supervisorctl)
+ serverurl = unix:///var/run//supervisor.sock (use a unix:// URL for a unix socket)
+ <include> /etc/supervisor/conf.d/*.conf
+ (program:test)
+ command = cat
+
+Note how the include directive has turned into a junction point (:class:`reconfigure.nodes.IncludeNode`) and content of included files was parsed and attached.
+
+Calling ``decompose`` method will split the tree back into separate files:
+
+ >>> includer.decompose(nodes)
+ {
+ '/etc/supervisor/conf.d/1.conf': <reconfigure.nodes.RootNode object at 0x2c5cf10>,
+ '/etc/supervisor/supervisord.conf': <reconfigure.nodes.RootNode object at 0x2c5cb50>
+ }
+
+Writing your own includer
+-------------------------
+
+If you're up to writing a custom includer, take a look at :class:`reconfigure.includers.AutoIncluder`. It already implements the tree-walking and attachment logic, so you only need to implement two methods:
+
+ * ``is_include(node)``: should check if the ``node`` is an include directive for this file format, and if it is, return a glob (wildcard) or path to the included files
+ * ``remove_include(include_node)``: given an :class:`reconfigure.nodes.IncludeNode`, should transform it back into file-format-specific include directive and return it (as a :ref:`node tree <node-tree>` chunk)
+
+
+.. _builder:
+
+Builders
+========
+
+Builders transform :ref:`node trees <node-tree>` into :ref:`data trees <data-tree>`.
+
+To write your own builder, subclass :class:`reconfigure.builders.BaseBuilder` and override ``build`` and ``unbuild`` methods. \ No newline at end of file
diff --git a/docs/source/docs/architecture/config.rst b/docs/source/docs/architecture/config.rst
new file mode 100644
index 0000000..768a7ad
--- /dev/null
+++ b/docs/source/docs/architecture/config.rst
@@ -0,0 +1,43 @@
+.. _reconfig:
+
+Reconfig objects
+****************
+
+:class:`reconfigure.config.Reconfig` objects are pre-set pipelines connecting :ref:`Parsers <parser>`, :ref:`Includers <includer>` and :ref:`Builders <builder>`
+
+Reconfigure comes with many Reconfig objects out-of-the-box - see :ref:`reconfigure.configs`
+
+Writing your Reconfig subclass
+==============================
+
+Use the following pattern::
+
+ class <name>Config (Reconfig):
+ """
+ <description>
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': <parser-class>(),
+ 'includer': <includer-class>(),
+ 'builder': BoundBuilder(<root-data-class>),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
+
+Example::
+
+ class SupervisorConfig (Reconfig):
+ """
+ ``/etc/supervisor/supervisord.conf``
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IniFileParser(),
+ 'includer': SupervisorIncluder(),
+ 'builder': BoundBuilder(SupervisorData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/docs/source/docs/architecture/trees.rst b/docs/source/docs/architecture/trees.rst
new file mode 100644
index 0000000..9f3bd1f
--- /dev/null
+++ b/docs/source/docs/architecture/trees.rst
@@ -0,0 +1,197 @@
+Trees
+*****
+
+Reconfigure operates with three types of data:
+
+* Raw config text
+* Syntax tree
+* Data tree
+
+.. _raw-config:
+
+Config text
+===========
+
+This is a raw content, as read from the config file. It is fed to :ref:`Parsers <parser>` to produce the :ref:`Syntax trees<node-tree>`.
+
+.. _node-tree:
+
+Syntax trees
+==========
+
+Syntax tree is an object tree built from :class:`reconfigure.nodes.Node` objects, representing the syntax structure of the file. This is very similar to Abstract Syntax Trees.
+
+Syntax trees are produced by :ref:`Parser` classes.
+
+Example::
+
+ >>> text = open('/etc/samba/smb.conf').read()
+ >>> text
+ '#\n# Sample configuration file for the Samba suite for Debian GNU/Linux.\
+ ...
+ >>> from reconfigure.parsers import IniFileParser
+ >>> parser = IniFileParser()
+ >>> node_tree = parser.parse(text)
+ >>> print node_tree
+ (None)
+ (global)
+ workgroup = WORKGROUP
+ server string = %h server (Samba, Ubuntu)
+ dns proxy = no
+ log file = /var/log/samba/log.%m
+ max log size = 1000
+ syslog = 0
+ panic action = /usr/share/samba/panic-action %d
+ encrypt passwords = true
+ passdb backend = tdbsam
+ obey pam restrictions = yes
+ unix password sync = yes
+ passwd program = /usr/bin/passwd %u
+ passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* .
+ pam password change = yes
+ map to guest = bad user
+ usershare allow guests = yes
+ (printers)
+ comment = All Printers
+ browseable = no
+ path = /var/spool/samba
+ printable = yes
+ guest ok = no
+ read only = yes
+ create mask = 0700
+ >>> node_tree
+ <reconfigure.nodes.RootNode object at 0x219a150>
+ >>> node_tree.children[0]
+ <reconfigure.nodes.Node object at 0x219a950>
+ >>> node_tree.children[0].name
+ 'global'
+ >>> node_tree.children[0].children[0]
+ <reconfigure.nodes.PropertyNode object at 0x219aa10>
+ >>> node_tree.children[0].children[0].name
+ 'workgroup'
+ >>> node_tree.children[0].children[0].value
+ 'WORKGROUP'
+
+:class:`reconfigure.nodes.Node` reference page contains more information on how to manipulate node trees.
+
+Parsers work both ways - you can call ``stringify()`` and get the text representation back. Even more, you can feed the node tree to *another* parser and get the config in other format::
+
+ >>> from reconfigure.parsers import JsonParser
+ >>> json_parser = JsonParser()
+ >>> json_parser.stringify(node_tree)
+ >>> print json_parser.stringify(node_tree)
+ {
+ "global": {
+ "encrypt passwords": "true",
+ "pam password change": "yes",
+ "passdb backend": "tdbsam",
+ "passwd program": "/usr/bin/passwd %u",
+ ...
+ },
+ "print$": {
+ "comment": "Printer Drivers",
+ "path": "/var/lib/samba/printers",
+ "read only": "yes",
+ ...
+
+Syntax trees might look useful to you, but they are not nearly as cool as :ref:`Data trees <data-tree>`
+
+.. _data-tree:
+
+Data trees
+==========
+
+Data tree represents the actual, meaningful ideas stored in the config. Straight to example::
+
+ >>> from reconfigure.builders import BoundBuilder
+ >>> from reconfigure.items.samba import SambaData
+ >>> builder = BoundBuilder(SambaData)
+ >>> data_tree = builder.build(node_tree)
+ >>> data_tree
+ {
+ "global": {
+ "server_string": "%h server (Samba, Ubuntu)",
+ "workgroup": "WORKGROUP",
+ "interfaces": "",
+ "bind_interfaces_only": true,
+ "security": "user",
+ "log_file": "/var/log/samba/log.%m"
+ },
+ "shares": [
+ {
+ "comment": "All Printers",
+ "browseable": false,
+ "create_mask": "0700",
+ "name": "printers",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/spool/samba"
+ },
+ {
+ "comment": "Printer Drivers",
+ "browseable": true,
+ "create_mask": "0744",
+ "name": "print$",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/lib/samba/printers"
+ }
+ ]
+ }
+
+ >>> data_tree.shares
+ <reconfigure.items.bound.BoundCollection object at 0x23d0610>
+ >>> [_.path for _ in data_tree.shares]
+ ['/var/spool/samba', '/var/lib/samba/printers']
+
+Data trees may consist of any Python objects, but the common approach is to use :ref:`Bound data`
+
+Data trees can be manipulated as you wish::
+
+ >>> from reconfigure.items.samba import ShareData
+ >>> share = ShareData()
+ >>> share.path = '/home/user'
+ >>> share.comment = 'New share'
+ >>> data_tree.shares.append(share)
+ >>> data_tree
+ {
+ ....
+ "shares": [
+ {
+ "comment": "All Printers",
+ "browseable": false,
+ "create_mask": "0700",
+ "name": "printers",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/spool/samba"
+ },
+ {
+ "comment": "Printer Drivers",
+ "browseable": true,
+ "create_mask": "0744",
+ "name": "print$",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/lib/samba/printers"
+ },
+ {
+ "comment": "New share",
+ "browseable": true,
+ "create_mask": "0744",
+ "name": "share",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/home/user"
+ }
+ ]
+
+After you're done with the modifications, the data tree must be converted back to the node tree::
+
+ >>> node_tree = builder.unbuild(data_tree)
+
diff --git a/docs/source/docs/quickstart.rst b/docs/source/docs/quickstart.rst
new file mode 100644
index 0000000..a0384e6
--- /dev/null
+++ b/docs/source/docs/quickstart.rst
@@ -0,0 +1,96 @@
+Quickstart
+==========
+
+Adding lines to ``fstab``::
+
+ >>> from reconfigure.configs import FSTabConfig
+ >>> from reconfigure.items.fstab import FilesystemData
+ >>>
+ >>> config = FSTabConfig(path='/etc/fstab')
+ >>> config.load()
+ >>> print config.tree
+ {
+ "filesystems": [
+ {
+ "passno": "0",
+ "device": "proc",
+ "mountpoint": "/proc",
+ "freq": "0",
+ "type": "proc",
+ "options": "nodev,noexec,nosuid"
+ },
+ {
+ "passno": "1",
+ "device": "UUID=dfccef1e-d46c-45b8-969d-51391898c55e",
+ "mountpoint": "/",
+ "freq": "0",
+ "type": "ext4",
+ "options": "errors=remount-ro"
+ }
+ ]
+ }
+ >>> tmpfs = FilesystemData()
+ >>> tmpfs.mountpoint = '/srv/cache'
+ >>> tmpfs.type = 'tmpfs'
+ >>> tmpfs.device = 'none'
+ >>> config.tree.filesystems.append(tmpfs)
+ >>> config.save()
+ >>> quit()
+ $ cat /etc/fstab
+ proc /proc proc nodev,noexec,nosuid 0 0
+ UUID=dfccef1e-d46c-45b8-969d-51391898c55e / ext4 errors=remount-ro 0 1
+ none /srv/cache tmpfs none 0 0
+
+Changing Samba settings::
+
+ >>> from reconfigure.configs import SambaConfig
+ >>> config = SambaConfig(path='/etc/samba/smb.conf')
+ >>> config.load()
+ >>> print config.tree.shares
+ [
+ {
+ "comment": "All Printers",
+ "browseable": false,
+ "create_mask": "0700",
+ "name": "printers",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/spool/samba"
+ },
+ {
+ "comment": "Printer Drivers",
+ "browseable": true,
+ "create_mask": "0744",
+ "name": "print$",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/lib/samba/printers"
+ }
+ ]
+ >>> config.tree.shares[0].guest_ok = True
+ >>> print config.tree.shares
+ [
+ {
+ "comment": "All Printers",
+ "browseable": false,
+ "create_mask": "0700",
+ "name": "printers",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": true,
+ "path": "/var/spool/samba"
+ },
+ {
+ "comment": "Printer Drivers",
+ "browseable": true,
+ "create_mask": "0744",
+ "name": "print$",
+ "directory_mask": "0755",
+ "read_only": true,
+ "guest_ok": false,
+ "path": "/var/lib/samba/printers"
+ }
+ ]
+ >>> config.save()
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..940f2bd
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,46 @@
+.. Reconfigure documentation master file, created by
+ sphinx-quickstart on Mon Aug 15 15:18:35 2011.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to Reconfigure's documentation!
+=======================================
+
+Links:
+------
+
+* `Source at GitHub <http://github.com/Eugeny/reconfigure>`_
+* Questions? `Email me <mailto:e@ajeni.org>`_
+* `PyPI <http://pypi.python.org/pypi?:action=display&name=reconfigure>`_
+* `CI <http://ajenti.org:9090/job/reconfigure/>`_
+
+Contents:
+---------
+
+.. toctree::
+ :maxdepth: 2
+
+ docs/quickstart
+ docs/architecture
+
+
+API Reference:
+--------------
+
+.. toctree::
+ :maxdepth: 2
+
+ ref/reconfigure.configs
+ ref/reconfigure.parsers
+ ref/reconfigure.nodes
+ ref/reconfigure.includers
+ ref/reconfigure.builders
+ ref/reconfigure.items.bound
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/source/ref/reconfigure.builders.rst b/docs/source/ref/reconfigure.builders.rst
new file mode 100644
index 0000000..a2edb2d
--- /dev/null
+++ b/docs/source/ref/reconfigure.builders.rst
@@ -0,0 +1,6 @@
+reconfigure.builders
+********************
+
+.. automodule:: reconfigure.builders
+ :members:
+ :undoc-members:
diff --git a/docs/source/ref/reconfigure.configs.rst b/docs/source/ref/reconfigure.configs.rst
new file mode 100644
index 0000000..afa99a5
--- /dev/null
+++ b/docs/source/ref/reconfigure.configs.rst
@@ -0,0 +1,8 @@
+.. _reconfigure.configs:
+
+reconfigure.configs
+*******************
+
+.. automodule:: reconfigure.configs
+ :members:
+ :undoc-members:
diff --git a/docs/source/ref/reconfigure.includers.rst b/docs/source/ref/reconfigure.includers.rst
new file mode 100644
index 0000000..1c8fdd6
--- /dev/null
+++ b/docs/source/ref/reconfigure.includers.rst
@@ -0,0 +1,6 @@
+reconfigure.includers
+*********************
+
+.. automodule:: reconfigure.includers
+ :members:
+ :undoc-members:
diff --git a/docs/source/ref/reconfigure.items.bound.rst b/docs/source/ref/reconfigure.items.bound.rst
new file mode 100644
index 0000000..2f12552
--- /dev/null
+++ b/docs/source/ref/reconfigure.items.bound.rst
@@ -0,0 +1,6 @@
+reconfigure.items.bound
+***********************
+
+.. automodule:: reconfigure.items.bound
+ :members:
+ :undoc-members:
diff --git a/docs/source/ref/reconfigure.nodes.rst b/docs/source/ref/reconfigure.nodes.rst
new file mode 100644
index 0000000..9d522f9
--- /dev/null
+++ b/docs/source/ref/reconfigure.nodes.rst
@@ -0,0 +1,6 @@
+reconfigure.nodes
+*****************
+
+.. automodule:: reconfigure.nodes
+ :members:
+ :undoc-members:
diff --git a/docs/source/ref/reconfigure.parsers.rst b/docs/source/ref/reconfigure.parsers.rst
new file mode 100644
index 0000000..45fc310
--- /dev/null
+++ b/docs/source/ref/reconfigure.parsers.rst
@@ -0,0 +1,6 @@
+reconfigure.parsers
+*******************
+
+.. automodule:: reconfigure.parsers
+ :members:
+ :undoc-members:
diff --git a/reconfigure.sublime-project b/reconfigure.sublime-project
new file mode 100644
index 0000000..24db303
--- /dev/null
+++ b/reconfigure.sublime-project
@@ -0,0 +1,8 @@
+{
+ "folders":
+ [
+ {
+ "path": "."
+ }
+ ]
+}
diff --git a/reconfigure/__init__.py b/reconfigure/__init__.py
new file mode 100644
index 0000000..a5f3762
--- /dev/null
+++ b/reconfigure/__init__.py
@@ -0,0 +1 @@
+__version__ = "0.1.29"
diff --git a/reconfigure/builders/__init__.py b/reconfigure/builders/__init__.py
new file mode 100644
index 0000000..d96710d
--- /dev/null
+++ b/reconfigure/builders/__init__.py
@@ -0,0 +1,16 @@
+"""
+Builders are used to convert Node Tree to Data Tree
+"""
+
+from base import BaseBuilder
+from bound import BoundBuilder
+#from nginx import NginxBuilder
+#from crontab import CrontabBuilder
+
+
+__all__ = [
+ 'BaseBuilder',
+ 'BoundBuilder',
+ #'NginxBuilder',
+ #'CrontabBuilder',
+]
diff --git a/reconfigure/builders/base.py b/reconfigure/builders/base.py
new file mode 100644
index 0000000..5708f72
--- /dev/null
+++ b/reconfigure/builders/base.py
@@ -0,0 +1,16 @@
+class BaseBuilder (object):
+ """
+ A base class for builders
+ """
+
+ def build(self, tree):
+ """
+ :param tree: :class:`reconfigure.nodes.Node` tree
+ :returns: Data tree
+ """
+
+ def unbuild(self, tree):
+ """
+ :param tree: Data tree
+ :returns: :class:`reconfigure.nodes.Node` tree
+ """
diff --git a/reconfigure/builders/bound.py b/reconfigure/builders/bound.py
new file mode 100644
index 0000000..a429a08
--- /dev/null
+++ b/reconfigure/builders/bound.py
@@ -0,0 +1,18 @@
+from reconfigure.builders.base import BaseBuilder
+
+
+class BoundBuilder (BaseBuilder):
+ """
+ A builder that uses :class:`reconfigure.items.bound.BoundData` to build stuff
+
+ :param root_class: a ``BoundData`` class that used as processing root
+ """
+
+ def __init__(self, root_class):
+ self.root_class = root_class
+
+ def build(self, nodetree):
+ return self.root_class(nodetree)
+
+ def unbuild(self, tree):
+ pass
diff --git a/reconfigure/builders/bound_tests.py b/reconfigure/builders/bound_tests.py
new file mode 100644
index 0000000..8c1faa6
--- /dev/null
+++ b/reconfigure/builders/bound_tests.py
@@ -0,0 +1,52 @@
+from reconfigure.items.bound import BoundData
+from reconfigure.nodes import Node, PropertyNode
+import unittest
+
+
+class BoundDataTest (unittest.TestCase):
+
+ def test_bind_property(self):
+ class TestBoundData (BoundData):
+ pass
+ TestBoundData.bind_property('prop', 'dataprop', getter=lambda x: 'd' + x, setter=lambda x: x[1:])
+
+ n = Node('name', children=[
+ PropertyNode('prop', 'value')
+ ])
+
+ d = TestBoundData(n)
+
+ self.assertEqual(d.dataprop, 'dvalue')
+ d.dataprop = 'dnew'
+ self.assertEqual(d.dataprop, 'dnew')
+ self.assertEqual(n.get('prop').value, 'new')
+
+ def test_bind_collection(self):
+ class TestBoundData (BoundData):
+ pass
+
+ class TestChildData (BoundData):
+ def template(self):
+ return Node('', children=[PropertyNode('value', None)])
+
+ TestBoundData.bind_collection('items', item_class=TestChildData, selector=lambda x: x.name != 'test')
+ TestChildData.bind_property('value', 'value')
+ n = Node('name', children=[
+ Node('1', children=[PropertyNode('value', 1)]),
+ Node('2', children=[PropertyNode('value', 2)]),
+ Node('test', children=[PropertyNode('value', 3)]),
+ Node('3', children=[PropertyNode('value', 3)]),
+ ])
+
+ d = TestBoundData(n)
+ self.assertEqual(d.items[0].value, 1)
+ self.assertEqual(len(d.items), 3)
+ c = TestChildData()
+ c.value = 4
+ d.items.append(c)
+ self.assertEqual(len(d.items), 4)
+ self.assertEqual(d.items[-1].value, 4)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/reconfigure/configs/__init__.py b/reconfigure/configs/__init__.py
new file mode 100644
index 0000000..6954547
--- /dev/null
+++ b/reconfigure/configs/__init__.py
@@ -0,0 +1,46 @@
+"""
+Configs are ready-to-use objects that link together Parsers, Includers and Builders to provide direct conversion between config files and Data tree.
+"""
+
+from base import Reconfig
+from ajenti import AjentiConfig
+from bind9 import BIND9Config
+from crontab import CrontabConfig
+from ctdb import CTDBConfig, CTDBNodesConfig, CTDBPublicAddressesConfig
+from dhcpd import DHCPDConfig
+from exports import ExportsConfig
+from fstab import FSTabConfig
+from group import GroupConfig
+from hosts import HostsConfig
+from iptables import IPTablesConfig
+from netatalk import NetatalkConfig
+from nsd import NSDConfig
+from passwd import PasswdConfig
+from resolv import ResolvConfig
+from samba import SambaConfig
+from squid import SquidConfig
+from supervisor import SupervisorConfig
+
+
+__all__ = [
+ 'Reconfig',
+ 'AjentiConfig',
+ 'BIND9Config',
+ 'CrontabConfig',
+ 'CTDBConfig',
+ 'CTDBNodesConfig',
+ 'CTDBPublicAddressesConfig',
+ 'DHCPDConfig',
+ 'ExportsConfig',
+ 'FSTabConfig',
+ 'GroupConfig',
+ 'HostsConfig',
+ 'IPTablesConfig',
+ 'NetatalkConfig',
+ 'NSDConfig',
+ 'PasswdConfig',
+ 'ResolvConfig',
+ 'SambaConfig',
+ 'SquidConfig',
+ 'SupervisorConfig',
+]
diff --git a/reconfigure/configs/ajenti.py b/reconfigure/configs/ajenti.py
new file mode 100644
index 0000000..1ecf9ab
--- /dev/null
+++ b/reconfigure/configs/ajenti.py
@@ -0,0 +1,14 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import JsonParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.ajenti import AjentiData
+
+
+class AjentiConfig (Reconfig):
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': JsonParser(),
+ 'builder': BoundBuilder(AjentiData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/base.py b/reconfigure/configs/base.py
new file mode 100644
index 0000000..0753246
--- /dev/null
+++ b/reconfigure/configs/base.py
@@ -0,0 +1,71 @@
+import chardet
+
+
+class Reconfig (object):
+ """
+ Basic config class. Derivatives normally only need to override the constructor.
+
+ Config data is loaded either from ``path`` or from ``content``
+
+ :param parser: overrides the Parser instance
+ :param includer: overrides the Includer instance
+ :param builder: overrides the Builder instance
+ :param path: config file path. Not compatible with ``content``
+ :param content: config file content. Not compatible with ``path``
+ """
+
+ def __init__(self, parser=None, includer=None, builder=None, path=None, content=None):
+ self.parser = parser
+ self.builder = builder
+ self.includer = includer
+ if self.includer is not None:
+ if not self.includer.parser:
+ self.includer.parser = self.parser
+ if path:
+ self.origin = path
+ self.content = None
+ else:
+ self.origin = None
+ self.content = content
+
+ def load(self):
+ """
+ Loads the config data, parses and builds it. Sets ``tree`` attribute to point to Data tree.
+ """
+ if self.origin:
+ self.content = open(self.origin, 'r').read()
+
+ try:
+ self.content = self.content.decode('utf8')
+ self.encoding = 'utf8'
+ except UnicodeDecodeError:
+ self.encoding = chardet.detect(self.content)['encoding']
+ self.content = self.content.decode(self.encoding)
+
+ self.nodetree = self.parser.parse(self.content)
+ if self.includer is not None:
+ self.nodetree = self.includer.compose(self.origin, self.nodetree)
+ if self.builder is not None:
+ self.tree = self.builder.build(self.nodetree)
+ return self
+
+ def save(self):
+ """
+ Unbuilds, stringifies and saves the config. If the config was loaded from string, returns ``{ origin: data }`` dict
+ """
+ tree = self.tree
+ if self.builder is not None:
+ nodetree = self.builder.unbuild(tree) or self.nodetree
+ if self.includer is not None:
+ nodetree = self.includer.decompose(nodetree)
+ else:
+ nodetree = {self.origin: nodetree}
+
+ result = {}
+ for k in nodetree:
+ result[k or self.origin] = self.parser.stringify(nodetree[k]).encode(self.encoding)
+
+ if self.origin is not None:
+ for k in result:
+ open(k, 'w').write(result[k])
+ return result
diff --git a/reconfigure/configs/bind9.py b/reconfigure/configs/bind9.py
new file mode 100644
index 0000000..8bf38a8
--- /dev/null
+++ b/reconfigure/configs/bind9.py
@@ -0,0 +1,19 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import BIND9Parser
+from reconfigure.includers import BIND9Includer
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.bind9 import BIND9Data
+
+
+class BIND9Config (Reconfig):
+ """
+ ``named.conf``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': BIND9Parser(),
+ 'includer': BIND9Includer(),
+ 'builder': BoundBuilder(BIND9Data),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/crontab.py b/reconfigure/configs/crontab.py
new file mode 100644
index 0000000..3d7e21a
--- /dev/null
+++ b/reconfigure/configs/crontab.py
@@ -0,0 +1,14 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import CrontabParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.crontab import CrontabData
+
+
+class CrontabConfig (Reconfig):
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': CrontabParser(),
+ 'builder': BoundBuilder(CrontabData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/ctdb.py b/reconfigure/configs/ctdb.py
new file mode 100644
index 0000000..491ac28
--- /dev/null
+++ b/reconfigure/configs/ctdb.py
@@ -0,0 +1,44 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import IniFileParser
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.ctdb import CTDBData, NodesData, PublicAddressesData
+
+
+class CTDBConfig (Reconfig):
+ """
+ ``CTDB main config``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IniFileParser(sectionless=True),
+ 'builder': BoundBuilder(CTDBData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
+
+
+class CTDBNodesConfig (Reconfig):
+ """
+ ``CTDB node list file``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(),
+ 'builder': BoundBuilder(NodesData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
+
+
+class CTDBPublicAddressesConfig (Reconfig):
+ """
+ ``CTDB public address list file``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(),
+ 'builder': BoundBuilder(PublicAddressesData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/dhcpd.py b/reconfigure/configs/dhcpd.py
new file mode 100644
index 0000000..0f52986
--- /dev/null
+++ b/reconfigure/configs/dhcpd.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import NginxParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.dhcpd import DHCPDData
+
+
+class DHCPDConfig (Reconfig):
+ """
+ ``DHCPD``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': NginxParser(),
+ 'builder': BoundBuilder(DHCPDData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/exports.py b/reconfigure/configs/exports.py
new file mode 100644
index 0000000..3f0296a
--- /dev/null
+++ b/reconfigure/configs/exports.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import ExportsParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.exports import ExportsData
+
+
+class ExportsConfig (Reconfig):
+ """
+ ``/etc/fstab``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': ExportsParser(),
+ 'builder': BoundBuilder(ExportsData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/fstab.py b/reconfigure/configs/fstab.py
new file mode 100644
index 0000000..69f21fe
--- /dev/null
+++ b/reconfigure/configs/fstab.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.fstab import FSTabData
+
+
+class FSTabConfig (Reconfig):
+ """
+ ``/etc/fstab``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(),
+ 'builder': BoundBuilder(FSTabData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/group.py b/reconfigure/configs/group.py
new file mode 100644
index 0000000..e769a04
--- /dev/null
+++ b/reconfigure/configs/group.py
@@ -0,0 +1,18 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.group import GroupsData
+
+
+class GroupConfig (Reconfig):
+ """
+ ``/etc/group``
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(separator=':'),
+ 'builder': BoundBuilder(GroupsData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/hosts.py b/reconfigure/configs/hosts.py
new file mode 100644
index 0000000..fbb1c3c
--- /dev/null
+++ b/reconfigure/configs/hosts.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.hosts import HostsData
+
+
+class HostsConfig (Reconfig):
+ """
+ ``/etc/hosts``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(),
+ 'builder': BoundBuilder(HostsData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/iptables.py b/reconfigure/configs/iptables.py
new file mode 100644
index 0000000..8a26211
--- /dev/null
+++ b/reconfigure/configs/iptables.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import IPTablesParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.iptables import IPTablesData
+
+
+class IPTablesConfig (Reconfig):
+ """
+ ``iptables-save`` and ``iptables-restore``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IPTablesParser(),
+ 'builder': BoundBuilder(IPTablesData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/netatalk.py b/reconfigure/configs/netatalk.py
new file mode 100644
index 0000000..6db2d21
--- /dev/null
+++ b/reconfigure/configs/netatalk.py
@@ -0,0 +1,18 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import IniFileParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.netatalk import NetatalkData
+
+
+class NetatalkConfig (Reconfig):
+ """
+ Netatalk afp.conf
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IniFileParser(),
+ 'builder': BoundBuilder(NetatalkData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/nsd.py b/reconfigure/configs/nsd.py
new file mode 100644
index 0000000..a313feb
--- /dev/null
+++ b/reconfigure/configs/nsd.py
@@ -0,0 +1,17 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import NSDParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.nsd import NSDData
+
+
+class NSDConfig (Reconfig):
+ """
+ ``NSD DNS server nsd.conf``
+ """
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': NSDParser(),
+ 'builder': BoundBuilder(NSDData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/passwd.py b/reconfigure/configs/passwd.py
new file mode 100644
index 0000000..7fb1e0c
--- /dev/null
+++ b/reconfigure/configs/passwd.py
@@ -0,0 +1,18 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.passwd import PasswdData
+
+
+class PasswdConfig (Reconfig):
+ """
+ ``/etc/passwd``
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(separator=':'),
+ 'builder': BoundBuilder(PasswdData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/resolv.py b/reconfigure/configs/resolv.py
new file mode 100644
index 0000000..41802b9
--- /dev/null
+++ b/reconfigure/configs/resolv.py
@@ -0,0 +1,18 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SSVParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.resolv import ResolvData
+
+
+class ResolvConfig (Reconfig):
+ """
+ ``/etc/resolv.conf``
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SSVParser(maxsplit=1),
+ 'builder': BoundBuilder(ResolvData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/samba.py b/reconfigure/configs/samba.py
new file mode 100644
index 0000000..32eec80
--- /dev/null
+++ b/reconfigure/configs/samba.py
@@ -0,0 +1,14 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import IniFileParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.samba import SambaData
+
+
+class SambaConfig (Reconfig):
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IniFileParser(),
+ 'builder': BoundBuilder(SambaData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/squid.py b/reconfigure/configs/squid.py
new file mode 100644
index 0000000..bfd1642
--- /dev/null
+++ b/reconfigure/configs/squid.py
@@ -0,0 +1,14 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import SquidParser
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.squid import SquidData
+
+
+class SquidConfig (Reconfig):
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': SquidParser(),
+ 'builder': BoundBuilder(SquidData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/configs/supervisor.py b/reconfigure/configs/supervisor.py
new file mode 100644
index 0000000..ef0fc95
--- /dev/null
+++ b/reconfigure/configs/supervisor.py
@@ -0,0 +1,20 @@
+from reconfigure.configs.base import Reconfig
+from reconfigure.parsers import IniFileParser
+from reconfigure.includers import SupervisorIncluder
+from reconfigure.builders import BoundBuilder
+from reconfigure.items.supervisor import SupervisorData
+
+
+class SupervisorConfig (Reconfig):
+ """
+ ``/etc/supervisor/supervisord.conf``
+ """
+
+ def __init__(self, **kwargs):
+ k = {
+ 'parser': IniFileParser(),
+ 'includer': SupervisorIncluder(),
+ 'builder': BoundBuilder(SupervisorData),
+ }
+ k.update(kwargs)
+ Reconfig.__init__(self, **k)
diff --git a/reconfigure/includers/__init__.py b/reconfigure/includers/__init__.py
new file mode 100644
index 0000000..1128588
--- /dev/null
+++ b/reconfigure/includers/__init__.py
@@ -0,0 +1,14 @@
+from base import BaseIncluder
+from auto import AutoIncluder
+from bind9 import BIND9Includer
+from nginx import NginxIncluder
+from supervisor import SupervisorIncluder
+
+
+__all__ = [
+ 'BaseIncluder',
+ 'AutoIncluder',
+ 'BIND9Includer',
+ 'NginxIncluder',
+ 'SupervisorIncluder',
+]
diff --git a/reconfigure/includers/auto.py b/reconfigure/includers/auto.py
new file mode 100644
index 0000000..16844ca
--- /dev/null
+++ b/reconfigure/includers/auto.py
@@ -0,0 +1,68 @@
+from base import BaseIncluder
+from reconfigure.nodes import *
+import glob
+import os
+
+
+class AutoIncluder (BaseIncluder):
+ """
+ This base includer automatically walks the node tree and loads the include files from ``IncludeNode.files`` properties. ``files`` is supposed to contain absolute path, relative path or a shell wildcard.
+ """
+
+ def compose(self, origin, tree):
+ self.compose_rec(origin, origin, tree)
+ return tree
+
+ def compose_rec(self, root, origin, node):
+ if not node.origin:
+ node.origin = origin
+ for child in node.children:
+ self.compose_rec(root, origin, child)
+ for child in node.children:
+ spec = self.is_include(child)
+ if spec:
+ files = spec
+ if node.origin and not files.startswith('/'):
+ files = os.path.join(os.path.split(root)[0], files)
+ if '*' in files or '.' in files:
+ files = glob.glob(files)
+ else:
+ files = [files]
+ for file in files:
+ if file in self.content_map:
+ content = self.content_map[file]
+ else:
+ content = open(file, 'r').read()
+ subtree = self.parser.parse(content)
+ node.children.extend(subtree.children)
+ self.compose_rec(root, file, subtree)
+ node.children[node.children.index(child)] = IncludeNode(spec)
+
+ def decompose(self, tree):
+ result = {}
+ result[tree.origin] = self.decompose_rec(tree, result)
+ return result
+
+ def decompose_rec(self, node, result):
+ for child in node.children:
+ if child.__class__ == IncludeNode:
+ replacement = self.remove_include(child)
+ if replacement:
+ node.children[node.children.index(child)] = replacement
+ else:
+ if child.origin != node.origin:
+ node.children.remove(child)
+ result.setdefault(child.origin, RootNode()).children.append(self.decompose_rec(child, result))
+ else:
+ self.decompose_rec(child, result)
+ return node
+
+ def is_include(self, node):
+ """
+ Should return whether the node is an include node and return file pattern glob if it is
+ """
+
+ def remove_include(self, node):
+ """
+ Shoud transform :class:`reconfigure.nodes.IncludeNode` into a normal Node to be stringified into the file
+ """
diff --git a/reconfigure/includers/base.py b/reconfigure/includers/base.py
new file mode 100644
index 0000000..e0512bf
--- /dev/null
+++ b/reconfigure/includers/base.py
@@ -0,0 +1,21 @@
+class BaseIncluder (object): # pragma: no cover
+ """
+ A base includer class
+
+ :param parser: Parser instance that was used to parse the root config file
+ :param content_map: a dict that overrides config content for specific paths
+ """
+
+ def __init__(self, parser=None, content_map={}):
+ self.parser = parser
+ self.content_map = content_map
+
+ def compose(self, origin, tree):
+ """
+ Should locate the include nodes in the Node tree, replace them with :class:`reconfigure.nodes.IncludeNode`, parse the specified include files and append them to tree, with correct node ``origin`` attributes
+ """
+
+ def decompose(self, origin, tree):
+ """
+ Should detach the included subtrees from the Node tree and return a ``{ origin: content-node-tree }`` dict.
+ """
diff --git a/reconfigure/includers/bind9.py b/reconfigure/includers/bind9.py
new file mode 100644
index 0000000..6a1e08c
--- /dev/null
+++ b/reconfigure/includers/bind9.py
@@ -0,0 +1,11 @@
+from reconfigure.includers.auto import AutoIncluder
+from reconfigure.nodes import PropertyNode
+
+
+class BIND9Includer (AutoIncluder):
+ def is_include(self, node):
+ if isinstance(node, PropertyNode) and node.name == 'include':
+ return node.value.strip('"')
+
+ def remove_include(self, node):
+ return PropertyNode('include', '"%s"' % node.files)
diff --git a/reconfigure/includers/nginx.py b/reconfigure/includers/nginx.py
new file mode 100644
index 0000000..f9c1c9b
--- /dev/null
+++ b/reconfigure/includers/nginx.py
@@ -0,0 +1,11 @@
+from reconfigure.includers.auto import AutoIncluder
+from reconfigure.nodes import PropertyNode
+
+
+class NginxIncluder (AutoIncluder):
+ def is_include(self, node):
+ if isinstance(node, PropertyNode) and node.name == 'include':
+ return node.value
+
+ def remove_include(self, node):
+ return PropertyNode('include', node.files)
diff --git a/reconfigure/includers/supervisor.py b/reconfigure/includers/supervisor.py
new file mode 100644
index 0000000..2314cd9
--- /dev/null
+++ b/reconfigure/includers/supervisor.py
@@ -0,0 +1,11 @@
+from reconfigure.includers.auto import AutoIncluder
+from reconfigure.nodes import Node, PropertyNode
+
+
+class SupervisorIncluder (AutoIncluder):
+ def is_include(self, node):
+ if node.name == 'include':
+ return node.get('files').value
+
+ def remove_include(self, node):
+ return Node('include', children=[PropertyNode('files', node.files)])
diff --git a/reconfigure/items/__init__.py b/reconfigure/items/__init__.py
new file mode 100644
index 0000000..b680692
--- /dev/null
+++ b/reconfigure/items/__init__.py
@@ -0,0 +1 @@
+__all__ = [] \ No newline at end of file
diff --git a/reconfigure/items/ajenti.py b/reconfigure/items/ajenti.py
new file mode 100644
index 0000000..3756c5c
--- /dev/null
+++ b/reconfigure/items/ajenti.py
@@ -0,0 +1,54 @@
+import json
+
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData, BoundDictionary
+
+
+class AjentiData (BoundData):
+ pass
+
+
+class HttpData (BoundData):
+ pass
+
+
+class SSLData (BoundData):
+ pass
+
+
+class UserData (BoundData):
+ def template(self):
+ return Node('unnamed',
+ PropertyNode('configs', {}),
+ PropertyNode('password', ''),
+ PropertyNode('permissions', []),
+ )
+
+
+class ConfigData (BoundData):
+ def template(self):
+ return PropertyNode('', '{}')
+
+
+AjentiData.bind_property('authentication', 'authentication')
+AjentiData.bind_property('installation_id', 'installation_id')
+AjentiData.bind_property('enable_feedback', 'enable_feedback')
+AjentiData.bind_child('http_binding', lambda x: x.get('bind'), item_class=HttpData)
+AjentiData.bind_child('ssl', lambda x: x.get('ssl'), item_class=SSLData)
+AjentiData.bind_collection('users', path=lambda x: x.get('users'), item_class=UserData, collection_class=BoundDictionary, key=lambda x: x.name)
+
+
+HttpData.bind_property('host', 'host')
+HttpData.bind_property('port', 'port')
+
+SSLData.bind_property('certificate_path', 'certificate_path')
+SSLData.bind_property('enable', 'enable')
+
+ConfigData.bind_name('name')
+
+UserData.bind_name('name')
+UserData.bind_property('password', 'password')
+UserData.bind_property('permissions', 'permissions')
+UserData.bind_collection('configs', lambda x: x.get('configs'), item_class=ConfigData, collection_class=BoundDictionary, key=lambda x: x.name)
+
+ConfigData.bind_attribute('value', 'data', getter=json.loads, setter=json.dumps)
diff --git a/reconfigure/items/bind9.py b/reconfigure/items/bind9.py
new file mode 100644
index 0000000..a42bb90
--- /dev/null
+++ b/reconfigure/items/bind9.py
@@ -0,0 +1,25 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class BIND9Data (BoundData):
+ pass
+
+
+class ZoneData (BoundData):
+ def template(self):
+ return Node(
+ 'zone',
+ PropertyNode('type', 'master'),
+ PropertyNode('file', 'db.example.com'),
+ parameter='"example.com"',
+ )
+
+
+quote = lambda x: '"%s"' % x
+unquote = lambda x: x.strip('"')
+
+BIND9Data.bind_collection('zones', selector=lambda x: x.name == 'zone', item_class=ZoneData)
+ZoneData.bind_attribute('parameter', 'name', getter=unquote, setter=quote)
+ZoneData.bind_property('type', 'type')
+ZoneData.bind_property('file', 'file', getter=unquote, setter=quote)
diff --git a/reconfigure/items/bound.py b/reconfigure/items/bound.py
new file mode 100644
index 0000000..f096965
--- /dev/null
+++ b/reconfigure/items/bound.py
@@ -0,0 +1,302 @@
+import json
+
+
+class BoundCollection (object):
+ """
+ Binds a list-like object to a set of nodes
+
+ :param node: target node (its children will be bound)
+ :param item_class: :class:`BoundData` class for items
+ :param selector: ``lambda x: bool``, used to filter out a subset of nodes
+ """
+
+ def __init__(self, node, item_class, selector=lambda x: True):
+ self.node = node
+ self.selector = selector
+ self.item_class = item_class
+ self.data = []
+ self.rebuild()
+
+ def rebuild(self):
+ """
+ Discards cached collection and rebuilds it from the nodes
+ """
+ del self.data[:]
+ for node in self.node.children:
+ if self.selector(node):
+ self.data.append(self.item_class(node))
+
+ def to_dict(self):
+ return [x.to_dict() if hasattr(x, 'to_dict') else x for x in self]
+
+ def to_json(self):
+ return json.dumps(self.to_dict(), indent=4)
+
+ def __str__(self):
+ return self.to_json()
+
+ def __iter__(self):
+ return self.data.__iter__()
+
+ def __getitem__(self, index):
+ return self.data.__getitem__(index)
+
+ def __len__(self):
+ return len(self.data)
+
+ def __contains__(self, item):
+ return item in self.data
+
+ def append(self, item):
+ self.node.append(item._node)
+ self.data.append(item)
+
+ def remove(self, item):
+ self.node.remove(item._node)
+ self.data.remove(item)
+
+ def insert(self, index, item):
+ self.node.children.insert(index, item._node)
+ self.data.insert(index, item)
+
+ def pop(self, index):
+ d = self[index]
+ self.remove(d)
+ return d
+
+
+class BoundDictionary (BoundCollection):
+ """
+ Binds a dict-like object to a set of nodes. Accepts same params as :class:`BoundCollection` plus ``key``
+
+ :param key: ``lambda value: object``, is used to get key for value in the collection
+ """
+
+ def __init__(self, key=None, **kwargs):
+ self.key = key
+ BoundCollection.__init__(self, **kwargs)
+
+ def rebuild(self):
+ BoundCollection.rebuild(self)
+ self.rebuild_dict()
+
+ def rebuild_dict(self):
+ self.datadict = dict((self.key(x), x) for x in self.data)
+
+ def to_dict(self):
+ return dict((k, x.to_dict() if hasattr(x, 'to_dict') else x) for k, x in self.iteritems())
+
+ def __getitem__(self, key):
+ self.rebuild_dict()
+ return self.datadict[key]
+
+ def __setitem__(self, key, value):
+ self.rebuild_dict()
+ if not key in self:
+ self.append(value)
+ self.datadict[key] = value
+
+ def __contains__(self, key):
+ self.rebuild_dict()
+ return key in self.datadict
+
+ def __iter__(self):
+ self.rebuild_dict()
+ return self.datadict.__iter__()
+
+ def iteritems(self):
+ return self.datadict.iteritems()
+
+ def setdefault(self, k, v):
+ if not k in self:
+ self[k] = v
+ self.append(v)
+ return self[k]
+
+ def values(self):
+ return self.data
+
+ def update(self, other):
+ for k, v in other.iteritems():
+ self[k] = v
+
+ def pop(self, key):
+ if key in self:
+ self.remove(self[key])
+
+
+class BoundData (object):
+ """
+ Binds itself to a node.
+
+ ``bind_*`` classmethods should be called on module-level, after subclass declaration.
+
+ :param node: all bindings will be relative to this node
+ :param kwargs: if ``node`` is ``None``, ``template(**kwargs)`` will be used to create node tree fragment
+ """
+
+ def __init__(self, node=None, **kwargs):
+ if not node:
+ node = self.template(**kwargs)
+ self._node = node
+
+ def template(self, **kwargs):
+ """
+ Override to create empty objects.
+
+ :returns: a :class:`reconfigure.nodes.Node` tree that will be used as a template for new BoundData instance
+ """
+ return None
+
+ def to_dict(self):
+ res_dict = {}
+ for attr_key in self.__class__.__dict__:
+ if attr_key in self.__class__._bound:
+ attr_value = getattr(self, attr_key)
+ if isinstance(attr_value, BoundData):
+ res_dict[attr_key] = attr_value.to_dict()
+ elif isinstance(attr_value, BoundCollection):
+ res_dict[attr_key] = attr_value.to_dict()
+ else:
+ res_dict[attr_key] = attr_value
+ return res_dict
+
+ def to_json(self):
+ return json.dumps(self.to_dict(), indent=4)
+
+ def __str__(self):
+ return self.to_json()
+
+ @classmethod
+ def bind(cls, data_property, getter, setter):
+ """
+ Creates an arbitrary named property in the class with given getter and setter. Not usually used directly.
+
+ :param data_property: property name
+ :param getter: ``lambda: object``, property getter
+ :param setter: ``lambda value: None``, property setter
+ """
+ if not hasattr(cls, '_bound'):
+ cls._bound = []
+ cls._bound.append(data_property)
+ setattr(cls, data_property, property(getter, setter))
+
+ @classmethod
+ def bind_property(cls, node_property, data_property, default=None, \
+ default_remove=[], \
+ path=lambda x: x, getter=lambda x: x, setter=lambda x: x):
+ """
+ Binds the value of a child :class:`reconfigure.node.PropertyNode` to a property
+
+ :param node_property: ``PropertyNode``'s ``name``
+ :param data_property: property name to be created
+ :param default: default value of the property (is ``PropertyNode`` doesn't exist)
+ :param default_remove: if setting a value contained in default_remove, the target property is removed
+ :param path: ``lambda self.node: PropertyNode``, can be used to point binding to another Node instead of ``self.node``.
+ :param getter: ``lambda object: object``, used to transform value when getting
+ :param setter: ``lambda object: object``, used to transform value when setting
+ """
+ def pget(self):
+ prop = path(self._node).get(node_property)
+ if prop:
+ return getter(prop.value)
+ else:
+ return default
+
+ def pset(self, value):
+ if setter(value) in default_remove:
+ node = path(self._node).get(node_property)
+ if node:
+ path(self._node).remove(node)
+ else:
+ path(self._node).set_property(node_property, setter(value))
+
+ cls.bind(data_property, pget, pset)
+
+ @classmethod
+ def bind_attribute(cls, node_attribute, data_property, default=None, \
+ path=lambda x: x, getter=lambda x: x, setter=lambda x: x):
+ """
+ Binds the value of node object's attribute to a property
+
+ :param node_attribute: ``Node``'s attribute name
+ :param data_property: property name to be created
+ :param default: default value of the property (is ``PropertyNode`` doesn't exist)
+ :param path: ``lambda self.node: PropertyNode``, can be used to point binding to another Node instead of ``self.node``.
+ :param getter: ``lambda object: object``, used to transform value when getting
+ :param setter: ``lambda object: object``, used to transform value when setting
+ """
+ def pget(self):
+ prop = getattr(path(self._node), node_attribute)
+ if prop:
+ return getter(prop)
+ else:
+ return getter(default)
+
+ def pset(self, value):
+ setattr(path(self._node), node_attribute, setter(value))
+
+ cls.bind(data_property, pget, pset)
+
+ @classmethod
+ def bind_collection(cls, data_property, path=lambda x: x, selector=lambda x: True, item_class=None, \
+ collection_class=BoundCollection, **kwargs):
+ """
+ Binds the subset of node's children to a collection property
+
+ :param data_property: property name to be created
+ :param path: ``lambda self.node: PropertyNode``, can be used to point binding to another Node instead of ``self.node``.
+ :param selector: ``lambda Node: bool``, can be used to filter out a subset of child nodes
+ :param item_class: a :class:`BoundData` subclass to be used for collection items
+ :param collection_class: a :class:`BoundCollection` subclass to be used for collection property itself
+ """
+ def pget(self):
+ if not hasattr(self, '__' + data_property):
+ setattr(self, '__' + data_property,
+ collection_class(
+ node=path(self._node),
+ item_class=item_class,
+ selector=selector,
+ **kwargs
+ )
+ )
+ return getattr(self, '__' + data_property)
+
+ cls.bind(data_property, pget, None)
+
+ @classmethod
+ def bind_name(cls, data_property, getter=lambda x: x, setter=lambda x: x):
+ """
+ Binds the value of node's ``name`` attribute to a property
+
+ :param data_property: property name to be created
+ :param getter: ``lambda object: object``, used to transform value when getting
+ :param setter: ``lambda object: object``, used to transform value when setting
+ """
+ def pget(self):
+ return getter(self._node.name)
+
+ def pset(self, value):
+ self._node.name = setter(value)
+
+ cls.bind(data_property, pget, pset)
+
+ @classmethod
+ def bind_child(cls, data_property, path=lambda x: x, item_class=None):
+ """
+ Directly binds a child Node to a BoundData property
+
+ :param data_property: property name to be created
+ :param path: ``lambda self.node: PropertyNode``, can be used to point binding to another Node instead of ``self.node``.
+ :param item_class: a :class:`BoundData` subclass to be used for the property value
+ """
+ def pget(self):
+ if not hasattr(self, '__' + data_property):
+ setattr(self, '__' + data_property,
+ item_class(
+ path(self._node),
+ )
+ )
+ return getattr(self, '__' + data_property)
+
+ cls.bind(data_property, pget, None)
diff --git a/reconfigure/items/crontab.py b/reconfigure/items/crontab.py
new file mode 100644
index 0000000..185ff94
--- /dev/null
+++ b/reconfigure/items/crontab.py
@@ -0,0 +1,64 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class CrontabData(BoundData):
+ """Data class for crontab configs"""
+ pass
+
+
+class CrontabNormalTaskData(BoundData):
+ fields = ['minute', 'hour', 'day_of_month', 'month', 'day_of_week', 'command']
+
+ def describe(self):
+ return ' '.join(getattr(self, x) for x in self.fields)
+
+ def template(self, **kwargs):
+ return Node('normal_task', children=[
+ PropertyNode('minute', '0'),
+ PropertyNode('hour', '0'),
+ PropertyNode('day_of_month', '1'),
+ PropertyNode('month', '1'),
+ PropertyNode('day_of_week', '1'),
+ PropertyNode('command', 'false')
+ ])
+
+
+class CrontabSpecialTaskData(BoundData):
+ fields = ['special', 'command']
+
+ def template(self, **kwargs):
+ return Node('special_task', children=[
+ PropertyNode('special', '@reboot'),
+ PropertyNode('command', 'false')
+ ])
+
+
+class CrontabEnvSettingData(BoundData):
+ fields = ['name', 'value']
+
+ def template(self, **kwargs):
+ return Node('env_setting', children=[
+ PropertyNode('name', 'ENV_NAME'),
+ PropertyNode('value', 'ENV_VALUE')
+ ])
+
+
+def bind_for_fields(bound_data_class):
+ for field in bound_data_class.fields:
+ bound_data_class.bind_property(field, field)
+
+CrontabData.bind_collection('normal_tasks', selector=lambda x: x.name == 'normal_task', item_class=CrontabNormalTaskData)
+bind_for_fields(CrontabNormalTaskData)
+
+CrontabNormalTaskData.bind_attribute('comment', 'comment')
+
+CrontabData.bind_collection('env_settings', selector=lambda x: x.name == 'env_setting', item_class=CrontabEnvSettingData)
+bind_for_fields(CrontabEnvSettingData)
+
+CrontabEnvSettingData.bind_attribute('comment', 'comment')
+
+CrontabData.bind_collection('special_tasks', selector=lambda x: x.name == 'special_task', item_class=CrontabSpecialTaskData)
+bind_for_fields(CrontabSpecialTaskData)
+
+CrontabSpecialTaskData.bind_attribute('comment', 'comment')
diff --git a/reconfigure/items/ctdb.py b/reconfigure/items/ctdb.py
new file mode 100644
index 0000000..f700c03
--- /dev/null
+++ b/reconfigure/items/ctdb.py
@@ -0,0 +1,50 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+from util import yn_getter, yn_setter
+
+
+class CTDBData (BoundData):
+ pass
+
+CTDBData.bind_property('CTDB_RECOVERY_LOCK', 'recovery_lock_file', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_PUBLIC_INTERFACE', 'public_interface', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_PUBLIC_ADDRESSES', 'public_addresses_file', default='/etc/ctdb/public_addresses', path=lambda x: x.get(None))
+CTDBData.bind_property(
+ 'CTDB_MANAGES_SAMBA', 'manages_samba', path=lambda x: x.get(None),
+ getter=yn_getter, setter=yn_setter)
+CTDBData.bind_property('CTDB_NODES', 'nodes_file', default='/etc/ctdb/nodes', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_LOGFILE', 'log_file', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_DEBUGLEVEL', 'debug_level', default='2', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_PUBLIC_NETWORK', 'public_network', default='', path=lambda x: x.get(None))
+CTDBData.bind_property('CTDB_PUBLIC_GATEWAY', 'public_gateway', default='', path=lambda x: x.get(None))
+
+
+class NodesData (BoundData):
+ pass
+
+
+class NodeData (BoundData):
+ def template(self):
+ return Node('line', children=[
+ Node('token', children=[PropertyNode('value', '127.0.0.1')]),
+ ])
+
+
+NodesData.bind_collection('nodes', item_class=NodeData)
+NodeData.bind_property('value', 'address', path=lambda x: x.children[0])
+
+
+class PublicAddressesData (BoundData):
+ pass
+
+
+class PublicAddressData (BoundData):
+ def template(self):
+ return Node('line', children=[
+ Node('token', children=[PropertyNode('value', '127.0.0.1')]),
+ Node('token', children=[PropertyNode('value', 'eth0')]),
+ ])
+
+PublicAddressesData.bind_collection('addresses', item_class=PublicAddressData)
+PublicAddressData.bind_property('value', 'address', path=lambda x: x.children[0])
+PublicAddressData.bind_property('value', 'interface', path=lambda x: x.children[1])
diff --git a/reconfigure/items/dhcpd.py b/reconfigure/items/dhcpd.py
new file mode 100644
index 0000000..aadda9a
--- /dev/null
+++ b/reconfigure/items/dhcpd.py
@@ -0,0 +1,35 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class DHCPDData (BoundData):
+ pass
+
+
+class SubnetData (BoundData):
+ def template(self):
+ return Node(
+ 'subnet',
+ parameter='192.168.0.0 netmask 255.255.255.0',
+ )
+
+
+class RangeData (BoundData):
+ def template(self):
+ return PropertyNode('range', '192.168.0.1 192.168.0.100')
+
+
+class OptionData (BoundData):
+ def template(self):
+ return PropertyNode('option', '')
+
+
+DHCPDData.bind_collection('subnets', selector=lambda x: x.name == 'subnet', item_class=SubnetData)
+SubnetData.bind_attribute('parameter', 'name')
+SubnetData.bind_collection('subnets', selector=lambda x: x.name == 'subnet', item_class=SubnetData)
+SubnetData.bind_collection('ranges', selector=lambda x: x.name == 'range', item_class=RangeData)
+RangeData.bind_attribute('value', 'range')
+OptionData.bind_attribute('value', 'value')
+
+for x in [DHCPDData, SubnetData]:
+ x.bind_collection('options', selector=lambda x: x.name == 'option', item_class=OptionData)
diff --git a/reconfigure/items/exports.py b/reconfigure/items/exports.py
new file mode 100644
index 0000000..5fe3a40
--- /dev/null
+++ b/reconfigure/items/exports.py
@@ -0,0 +1,30 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class ExportsData (BoundData):
+ pass
+
+
+class ExportData (BoundData):
+ def template(self):
+ return Node(
+ '/',
+ Node('clients')
+ )
+
+
+class ClientData (BoundData):
+ def template(self):
+ return Node(
+ 'localhost',
+ PropertyNode('options', '')
+ )
+
+
+ExportsData.bind_collection('exports', item_class=ExportData)
+ExportData.bind_name('name')
+ExportData.bind_attribute('comment', 'comment', default='')
+ExportData.bind_collection('clients', path=lambda x: x['clients'], item_class=ClientData)
+ClientData.bind_name('name')
+ClientData.bind_property('options', 'options')
diff --git a/reconfigure/items/fstab.py b/reconfigure/items/fstab.py
new file mode 100644
index 0000000..81b8060
--- /dev/null
+++ b/reconfigure/items/fstab.py
@@ -0,0 +1,26 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class FSTabData (BoundData):
+ pass
+
+
+class FilesystemData (BoundData):
+ fields = ['device', 'mountpoint', 'type', 'options', 'freq', 'passno']
+
+ def template(self):
+ return Node('line', children=[
+ Node('token', children=[PropertyNode('value', 'none')]),
+ Node('token', children=[PropertyNode('value', 'none')]),
+ Node('token', children=[PropertyNode('value', 'auto')]),
+ Node('token', children=[PropertyNode('value', 'none')]),
+ Node('token', children=[PropertyNode('value', '0')]),
+ Node('token', children=[PropertyNode('value', '0')]),
+ ])
+
+
+FSTabData.bind_collection('filesystems', item_class=FilesystemData)
+for i in range(0, len(FilesystemData.fields)):
+ path = lambda i: lambda x: x.children[i]
+ FilesystemData.bind_property('value', FilesystemData.fields[i], path=path(i))
diff --git a/reconfigure/items/group.py b/reconfigure/items/group.py
new file mode 100644
index 0000000..f95b28a
--- /dev/null
+++ b/reconfigure/items/group.py
@@ -0,0 +1,15 @@
+from reconfigure.items.bound import BoundData
+
+
+class GroupsData (BoundData):
+ pass
+
+
+class GroupData (BoundData):
+ fields = ['name', 'password', 'gid', 'users']
+
+
+GroupsData.bind_collection('groups', item_class=GroupData)
+for i in range(0, len(GroupData.fields)):
+ path = lambda i: lambda x: x.children[i]
+ GroupData.bind_property('value', GroupData.fields[i], path=path(i))
diff --git a/reconfigure/items/hosts.py b/reconfigure/items/hosts.py
new file mode 100644
index 0000000..74904dc
--- /dev/null
+++ b/reconfigure/items/hosts.py
@@ -0,0 +1,26 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class HostsData (BoundData):
+ pass
+
+
+class HostData (BoundData):
+ def template(self):
+ return Node('line', children=[
+ Node('token', children=[PropertyNode('value', '127.0.0.1')]),
+ Node('token', children=[PropertyNode('value', 'localhost')]),
+ ])
+
+
+class AliasData (BoundData):
+ def template(self):
+ return Node()
+
+
+HostsData.bind_collection('hosts', item_class=HostData)
+HostData.bind_property('value', 'address', path=lambda x: x.children[0])
+HostData.bind_property('value', 'name', path=lambda x: x.children[1])
+HostData.bind_collection('aliases', item_class=AliasData, selector=lambda x: x.parent.indexof(x) > 1)
+AliasData.bind_property('value', 'name')
diff --git a/reconfigure/items/iptables.py b/reconfigure/items/iptables.py
new file mode 100644
index 0000000..d4656ff
--- /dev/null
+++ b/reconfigure/items/iptables.py
@@ -0,0 +1,120 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class IPTablesData (BoundData):
+ pass
+
+
+class TableData (BoundData):
+ def template(self):
+ return Node('custom')
+
+
+class ChainData (BoundData):
+ def template(self):
+ return Node(
+ 'CUSTOM',
+ PropertyNode('default', '-'),
+ )
+
+
+class RuleData (BoundData):
+ def template(self):
+ return Node(
+ 'append',
+ Node(
+ 'option',
+ Node('argument', PropertyNode('value', 'ACCEPT')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'j'),
+ )
+ )
+
+ @property
+ def summary(self):
+ return ' '.join((
+ ('! ' if x.negative else '') +
+ ('-' if len(x.name) == 1 else '--') + x.name + ' ' +
+ ' '.join(a.value for a in x.arguments))
+ for x in self.options
+ )
+
+ def verify(self):
+ protocol_option = None
+ for option in self.options:
+ if option.name in ['p', 'protocol']:
+ self.options.remove(option)
+ self.options.insert(0, option)
+ protocol_option = option
+ for option in self.options:
+ if 'port' in option.name:
+ if not protocol_option:
+ protocol_option = OptionData.create('protocol')
+ self.options.insert(0, protocol_option)
+
+ def get_option(self, *names):
+ for name in names:
+ for option in self.options:
+ if option.name == name:
+ return option
+
+
+class OptionData (BoundData):
+ templates = {
+ 'protocol': ['protocol', ['tcp']],
+ 'match': ['match', ['multiport']],
+ 'source': ['source', ['127.0.0.1']],
+ 'mac-source': ['mac-source', ['00:00:00:00:00:00']],
+ 'destination': ['destination', ['127.0.0.1']],
+ 'in-interface': ['in-interface', ['lo']],
+ 'out-interface': ['out-interface', ['lo']],
+ 'source-port': ['source-port', ['80']],
+ 'source-ports': ['source-ports', ['80,443']],
+ 'destination-port': ['destination-port', ['80']],
+ 'destination-ports': ['destination-ports', ['80,443']],
+ 'state': ['state', ['NEW']],
+ 'reject-with': ['reject-with', ['icmp-net-unreachable']],
+ 'custom': ['name', ['value']],
+ }
+
+ @staticmethod
+ def create(template_id):
+ print 'new'
+ t = OptionData.templates[template_id]
+ return OptionData(Node(
+ 'option',
+ *(
+ [Node('argument', PropertyNode('value', x)) for x in t[1]]
+ + [PropertyNode('negative', False)]
+ + [PropertyNode('name', t[0])]
+ )
+ ))
+
+ @staticmethod
+ def create_destination():
+ print 'new'
+ return OptionData(Node(
+ 'option',
+ Node('argument', PropertyNode('value', 'ACCEPT')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'j'),
+ ))
+
+
+class ArgumentData (BoundData):
+ pass
+
+
+IPTablesData.bind_collection('tables', item_class=TableData)
+TableData.bind_collection('chains', item_class=ChainData)
+TableData.bind_name('name')
+ChainData.bind_property('default', 'default')
+ChainData.bind_collection('rules', selector=lambda x: x.name == 'append', item_class=RuleData)
+ChainData.bind_name('name')
+RuleData.bind_collection('options', item_class=OptionData)
+RuleData.bind_attribute('comment', 'comment')
+OptionData.bind_property('name', 'name')
+OptionData.bind_property('negative', 'negative')
+OptionData.bind_collection('arguments', selector=lambda x: x.name == 'argument', item_class=ArgumentData)
+ArgumentData.bind_property('value', 'value')
diff --git a/reconfigure/items/netatalk.py b/reconfigure/items/netatalk.py
new file mode 100644
index 0000000..e4957cf
--- /dev/null
+++ b/reconfigure/items/netatalk.py
@@ -0,0 +1,38 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+from util import yn_getter, yn_setter
+
+
+class NetatalkData (BoundData):
+ pass
+
+
+class GlobalData (BoundData):
+ pass
+
+
+class ShareData (BoundData):
+ fields = ['path', 'appledouble', 'valid users', 'cnid scheme', 'ea', 'password']
+ defaults = ['', 'ea', '', 'dbd', 'none', '']
+
+ def template(self):
+ return Node(
+ 'share',
+ *[PropertyNode(x, y) for x, y in zip(ShareData.fields, ShareData.defaults)]
+ )
+
+
+NetatalkData.bind_child('global', lambda x: x.get('Global'), item_class=GlobalData)
+NetatalkData.bind_collection('shares', selector=lambda x: x.name != 'Global', item_class=ShareData)
+
+
+GlobalData.bind_property('afp port', 'afp_port', default='548')
+GlobalData.bind_property('cnid listen', 'cnid_listen', default='localhost:4700')
+GlobalData.bind_property(
+ 'zeroconf', 'zeroconf', default=True,
+ getter=yn_getter, setter=yn_setter)
+
+ShareData.bind_name('name')
+ShareData.bind_attribute('comment', 'comment', path=lambda x: x.get('path'), default='')
+for f, d in zip(ShareData.fields, ShareData.defaults):
+ ShareData.bind_property(f, f.replace(' ', '_'), default=d)
diff --git a/reconfigure/items/nsd.py b/reconfigure/items/nsd.py
new file mode 100644
index 0000000..df20d95
--- /dev/null
+++ b/reconfigure/items/nsd.py
@@ -0,0 +1,23 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class NSDData (BoundData):
+ pass
+
+
+class ZoneData (BoundData):
+ def template(self):
+ return Node(
+ 'zone',
+ PropertyNode('name', '"example.com"'),
+ PropertyNode('file', '"example.com.zone"'),
+ )
+
+
+quote = lambda x: '"%s"' % x
+unquote = lambda x: x.strip('"')
+
+NSDData.bind_collection('zones', selector=lambda x: x.name == 'zone', item_class=ZoneData)
+ZoneData.bind_property('name', 'name', getter=unquote, setter=quote)
+ZoneData.bind_property('zonefile', 'file', getter=unquote, setter=quote)
diff --git a/reconfigure/items/passwd.py b/reconfigure/items/passwd.py
new file mode 100644
index 0000000..147bc8a
--- /dev/null
+++ b/reconfigure/items/passwd.py
@@ -0,0 +1,15 @@
+from reconfigure.items.bound import BoundData
+
+
+class PasswdData (BoundData):
+ pass
+
+
+class UserData (BoundData):
+ fields = ['name', 'password', 'uid', 'gid', 'comment', 'home', 'shell']
+
+
+PasswdData.bind_collection('users', item_class=UserData)
+for i in range(0, len(UserData.fields)):
+ path = lambda i: lambda x: x.children[i]
+ UserData.bind_property('value', UserData.fields[i], path=path(i))
diff --git a/reconfigure/items/resolv.py b/reconfigure/items/resolv.py
new file mode 100644
index 0000000..dd9a009
--- /dev/null
+++ b/reconfigure/items/resolv.py
@@ -0,0 +1,19 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class ResolvData (BoundData):
+ pass
+
+
+class ItemData (BoundData):
+ def template(self):
+ return Node('line', children=[
+ Node('token', children=[PropertyNode('value', 'nameserver')]),
+ Node('token', children=[PropertyNode('value', '8.8.8.8')]),
+ ])
+
+
+ResolvData.bind_collection('items', item_class=ItemData)
+ItemData.bind_property('value', 'name', path=lambda x: x.children[0])
+ItemData.bind_property('value', 'value', path=lambda x: x.children[1])
diff --git a/reconfigure/items/samba.py b/reconfigure/items/samba.py
new file mode 100644
index 0000000..079da5e
--- /dev/null
+++ b/reconfigure/items/samba.py
@@ -0,0 +1,59 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+from util import yn_getter, yn_setter
+
+
+class SambaData (BoundData):
+ pass
+
+
+class GlobalData (BoundData):
+ pass
+
+
+class ShareData (BoundData):
+ fields = [
+ 'comment', 'path', 'guest ok', 'browseable', 'create mask', 'directory mask', 'read only',
+ 'follow symlinks', 'wide links',
+ ]
+ defaults = [
+ '', '', 'no', 'yes', '0744', '0755', 'yes',
+ 'yes', 'no',
+ ]
+
+ def template(self):
+ return Node(
+ 'share',
+ *[PropertyNode(x, y) for x, y in zip(ShareData.fields, ShareData.defaults)]
+ )
+
+
+SambaData.bind_child('global', lambda x: x.get('global'), item_class=GlobalData)
+SambaData.bind_collection('shares', selector=lambda x: x.name != 'global', item_class=ShareData)
+
+
+GlobalData.bind_property('workgroup', 'workgroup', default='')
+GlobalData.bind_property('server string', 'server_string', default='')
+GlobalData.bind_property('interfaces', 'interfaces', default='')
+GlobalData.bind_property(
+ 'bind interfaces only', 'bind_interfaces_only', default=True,
+ getter=yn_getter, setter=yn_setter)
+GlobalData.bind_property('log file', 'log_file', default='')
+GlobalData.bind_property('security', 'security', default='user')
+
+ShareData.bind_name('name')
+ShareData.bind_property('path', 'path', default='')
+ShareData.bind_property('comment', 'comment', default='')
+ShareData.bind_property('create mask', 'create_mask', default='0744')
+ShareData.bind_property('directory mask', 'directory_mask', default='0755')
+
+for x, y in [
+ ('guest ok', False),
+ ('browseable', True),
+ ('read only', True),
+ ('follow symlinks', True),
+ ('wide links', False),
+]:
+ ShareData.bind_property(
+ x, x.replace(' ', '_'), default=y,
+ getter=yn_getter, setter=yn_setter)
diff --git a/reconfigure/items/squid.py b/reconfigure/items/squid.py
new file mode 100644
index 0000000..2504b55
--- /dev/null
+++ b/reconfigure/items/squid.py
@@ -0,0 +1,95 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class SquidData (BoundData):
+ pass
+
+
+class ACLData (BoundData):
+ def template(self, name, *args):
+ children = [PropertyNode('1', name)]
+ index = 2
+ for arg in args:
+ children += [PropertyNode(str(index), arg)]
+ index += 1
+ return Node(
+ 'line',
+ PropertyNode('name', 'acl'),
+ Node(
+ 'arguments',
+ *children
+ )
+ )
+
+ def describe(self):
+ return ' '.join(x.value for x in self.options)
+
+
+class HTTPAccessData (BoundData):
+ def template(self):
+ return Node(
+ 'line',
+ PropertyNode('name', 'http_access'),
+ Node('arguments', PropertyNode('1', ''))
+ )
+
+
+class HTTPPortData (BoundData):
+ def template(self):
+ return Node(
+ 'line',
+ PropertyNode('name', 'http_port'),
+ Node('arguments', PropertyNode('1', '3128'))
+ )
+
+
+class HTTPSPortData (BoundData):
+ def template(self):
+ return Node(
+ 'line',
+ PropertyNode('name', 'https_port'),
+ Node('arguments', PropertyNode('1', '3128'))
+ )
+
+
+class ArgumentData (BoundData):
+ def template(self, *args):
+ return PropertyNode(*args)
+
+
+def __bind_by_name(cls, prop, name, itemcls):
+ cls.bind_collection(
+ prop,
+ selector=lambda x: x.get('name').value == name,
+ item_class=itemcls
+ )
+
+__bind_by_name(SquidData, 'acl', 'acl', ACLData)
+__bind_by_name(SquidData, 'http_access', 'http_access', HTTPAccessData)
+__bind_by_name(SquidData, 'http_port', 'http_port', HTTPPortData)
+__bind_by_name(SquidData, 'https_port', 'https_port', HTTPSPortData)
+
+
+def __bind_first_arg(cls, prop):
+ cls.bind_attribute('value', prop, path=lambda x: x.get('arguments').children[0])
+
+
+def __bind_other_args(cls, prop, itemcls):
+ cls.bind_collection(
+ prop, path=lambda x: x.get('arguments'),
+ selector=lambda x: x.parent.children.index(x) > 0, item_class=itemcls
+ )
+
+__bind_first_arg(ACLData, 'name')
+__bind_other_args(ACLData, 'options', ArgumentData)
+
+__bind_first_arg(HTTPAccessData, 'mode')
+__bind_other_args(HTTPAccessData, 'options', ArgumentData)
+
+__bind_first_arg(HTTPPortData, 'port')
+__bind_other_args(HTTPPortData, 'options', ArgumentData)
+__bind_first_arg(HTTPSPortData, 'port')
+__bind_other_args(HTTPSPortData, 'options', ArgumentData)
+
+ArgumentData.bind_attribute('value', 'value')
diff --git a/reconfigure/items/supervisor.py b/reconfigure/items/supervisor.py
new file mode 100644
index 0000000..a8e462f
--- /dev/null
+++ b/reconfigure/items/supervisor.py
@@ -0,0 +1,22 @@
+from reconfigure.nodes import Node, PropertyNode
+from reconfigure.items.bound import BoundData
+
+
+class SupervisorData (BoundData):
+ pass
+
+
+class ProgramData (BoundData):
+ fields = ['command', 'autostart', 'autorestart', 'startsecs', 'startretries', \
+ 'user', 'directory', 'umask', 'environment']
+
+ def template(self):
+ return Node('program:new',
+ PropertyNode('command', '127.0.0.1'),
+ )
+
+
+SupervisorData.bind_collection('programs', item_class=ProgramData, selector=lambda x: x.name.startswith('program:'))
+ProgramData.bind_name('name', getter=lambda x: x[8:], setter=lambda x: 'program:%s' % x)
+for i in range(0, len(ProgramData.fields)):
+ ProgramData.bind_property(ProgramData.fields[i], ProgramData.fields[i], default_remove=[None, ''])
diff --git a/reconfigure/items/util.py b/reconfigure/items/util.py
new file mode 100644
index 0000000..0a615ed
--- /dev/null
+++ b/reconfigure/items/util.py
@@ -0,0 +1,3 @@
+yn_getter = lambda x: x == 'yes'
+
+yn_setter = lambda x: 'yes' if x else 'no'
diff --git a/reconfigure/nodes.py b/reconfigure/nodes.py
new file mode 100644
index 0000000..3335ad5
--- /dev/null
+++ b/reconfigure/nodes.py
@@ -0,0 +1,184 @@
+class Node (object):
+ """
+ A base node class for the Node Tree.
+ This class represents a named container node.
+ """
+
+ def __init__(self, name=None, *args, **kwargs):
+ """
+ :param name: Node name
+ :param *args: Children
+ :param comment: Node comment string
+ :param origin: Node's source location (usually path to the file)
+ """
+ self.name = name
+ self.origin = None
+ self.children = []
+ for node in list(args) + kwargs.pop('children', []):
+ self.append(node)
+ self.comment = kwargs.pop('comment', None)
+ self.__dict__.update(kwargs)
+
+ def __str__(self):
+ s = '(%s)' % self.name
+ if self.comment:
+ s += ' (%s)' % self.comment
+ s += '\n'
+ for child in self.children:
+ s += '\n'.join('\t' + x for x in str(child).splitlines()) + '\n'
+ return s
+
+ def __hash__(self):
+ return sum(hash(x) for x in [self.name, self.origin, self.comment] + self.children)
+
+ def __eq__(self, other):
+ if other is None:
+ return False
+
+ return \
+ self.name == other.name and \
+ self.comment == other.comment and \
+ self.origin == other.origin and \
+ set(self.children) == set(other.children)
+
+ def __iter__(self):
+ return iter(self.children)
+
+ def __len__(self):
+ return len(self.children)
+
+ def __nonzero__(self):
+ return True
+
+ def __getitem__(self, key):
+ if type(key) in (int, slice):
+ return self.children[key]
+ return self.get(key)
+
+ def __setitem__(self, key, value):
+ if type(key) is int:
+ self.children[key] = value
+ self.set_property(key, value)
+
+ def __contains__(self, item):
+ return item in self.children
+
+ def indexof(self, node):
+ """
+ :returns: index of the node in the children array or ``None`` if it's not a child
+ """
+ if node in self.children:
+ return self.children.index(node)
+ else:
+ return None
+
+ def get(self, name, default=None):
+ """
+ :returns: a child node by its name or ``default``
+ """
+ for child in self.children:
+ if child.name == name:
+ return child
+ if default:
+ self.append(default)
+ return default
+
+ def get_all(self, name):
+ """
+ :returns: list of child nodes with supplied ``name``
+ """
+ return [n for n in self.children if n.name == name]
+
+ def append(self, node):
+ if not node.origin:
+ node.origin = self.origin
+ self.children.append(node)
+ node.parent = self
+
+ def remove(self, node):
+ self.children.remove(node)
+
+ def replace(self, name, node=None):
+ """
+ Replaces the child nodes by ``name``
+
+ :param node: replacement node or list of nodes
+
+ ::
+
+ n.append(Node('a'))
+ n.append(Node('a'))
+ n.replace('a', None)
+ assert(len(n.get_all('a')) == 0)
+
+ """
+ if name:
+ self.children = [c for c in self.children if c.name != name]
+ if node is not None:
+ if type(node) == list:
+ for n in node:
+ self.children.append(n)
+ else:
+ self.children.append(node)
+
+ def set_property(self, name, value):
+ """
+ Creates or replaces a child :class:`PropertyNode` by name.
+ """
+ node = self.get(name)
+ if not node:
+ node = PropertyNode(name, value)
+ self.append(node)
+ node.value = value
+ return self
+
+
+class RootNode (Node):
+ """
+ A special node class that indicates tree root
+ """
+
+
+class PropertyNode (Node):
+ """
+ A node that serves as a property of its parent node.
+ """
+
+ def __init__(self, name, value, comment=None):
+ """
+ :param name: Property name
+ :param value: Property value
+ """
+ Node.__init__(self, name, comment=comment)
+ self.value = value
+
+ def __eq__(self, other):
+ if other is None:
+ return False
+
+ return \
+ Node.__eq__(self, other) and \
+ self.value == other.value
+
+ def __str__(self):
+ s = '%s = %s' % (self.name, self.value)
+ if self.comment:
+ s += ' (%s)' % self.comment
+ return s
+
+
+class IncludeNode (Node):
+ """
+ A node that indicates a junction point between two config files
+ """
+
+ def __init__(self, files):
+ """
+ :param files: an includer-dependent config location specifier
+ """
+ Node.__init__(self)
+ self.name = '<include>'
+ self.files = files
+
+ def __str__(self):
+ return '<include> %s' % self.files
diff --git a/reconfigure/parsers/__init__.py b/reconfigure/parsers/__init__.py
new file mode 100644
index 0000000..8de7aeb
--- /dev/null
+++ b/reconfigure/parsers/__init__.py
@@ -0,0 +1,25 @@
+from base import BaseParser
+from bind9 import BIND9Parser
+from exports import ExportsParser
+from ini import IniFileParser
+from iptables import IPTablesParser
+from jsonparser import JsonParser
+from nginx import NginxParser
+from nsd import NSDParser
+from ssv import SSVParser
+from squid import SquidParser
+from crontab import CrontabParser
+
+__all__ = [
+ 'BaseParser',
+ 'BIND9Parser',
+ 'CrontabParser',
+ 'ExportsParser',
+ 'IniFileParser',
+ 'IPTablesParser',
+ 'JsonParser',
+ 'NginxParser',
+ 'NSDParser',
+ 'SSVParser',
+ 'SquidParser',
+]
diff --git a/reconfigure/parsers/base.py b/reconfigure/parsers/base.py
new file mode 100644
index 0000000..321fe1b
--- /dev/null
+++ b/reconfigure/parsers/base.py
@@ -0,0 +1,18 @@
+class BaseParser (object): # pragma: no cover
+ """
+ A base parser class
+ """
+
+ def parse(self, content):
+ """
+ :param content: string config content
+ :returns: a :class:`reconfigure.nodes.Node` tree
+ """
+ return None
+
+ def stringify(self, tree):
+ """
+ :param tree: a :class:`reconfigure.nodes.Node` tree
+ :returns: string config content
+ """
+ return None
diff --git a/reconfigure/parsers/bind9.py b/reconfigure/parsers/bind9.py
new file mode 100644
index 0000000..d5c8e01
--- /dev/null
+++ b/reconfigure/parsers/bind9.py
@@ -0,0 +1,20 @@
+from reconfigure.nodes import *
+from reconfigure.parsers.nginx import NginxParser
+
+
+class BIND9Parser (NginxParser):
+ """
+ A parser for named.conf
+ """
+
+ tokens = [
+ (r"[\w_]+\s*?.*?{", lambda s, t: ('section_start', t)),
+ (r"[\w\d_:]+?.*?;", lambda s, t: ('option', t)),
+ (r"\s", lambda s, t: 'whitespace'),
+ (r"$^", lambda s, t: 'newline'),
+ (r"\#.*?\n", lambda s, t: ('comment', t)),
+ (r"//.*?\n", lambda s, t: ('comment', t)),
+ (r"/\*.*?\*/", lambda s, t: ('comment', t)),
+ (r"\};", lambda s, t: 'section_end'),
+ ]
+ token_section_end = '};'
diff --git a/reconfigure/parsers/crontab.py b/reconfigure/parsers/crontab.py
new file mode 100644
index 0000000..88dadba
--- /dev/null
+++ b/reconfigure/parsers/crontab.py
@@ -0,0 +1,82 @@
+from reconfigure.nodes import RootNode, Node, PropertyNode
+from reconfigure.parsers import BaseParser
+
+
+class CrontabParser(BaseParser):
+
+ def __init__(self, remove_comments=False):
+ self.remove_comments = remove_comments
+
+ def parse(self, content):
+ root = RootNode()
+ lines = [l.strip() for l in content.splitlines() if l]
+ comment = None
+ for line in lines:
+ if line.startswith('#'):
+ comment = '\n'.join([comment, line]) if comment else line[1:]
+ continue
+ elif line.startswith('@'):
+ special, command = line.split(' ', 1)
+ node = Node('special_task', comment=comment)
+ node.append(PropertyNode('special', special))
+ node.append(PropertyNode('command', command))
+
+ else:
+ split_line = line.split(' ', 5)
+ if len(split_line) <= 3 and '=' in line:
+ name, value = [n.strip() for n in line.split('=')]
+ if not name:
+ continue
+ node = Node('env_setting', comment=comment)
+ node.append(PropertyNode('name', name))
+ node.append(PropertyNode('value', value))
+ elif len(split_line) == 6:
+ node = Node('normal_task', comment=comment)
+ node.append(PropertyNode('minute', split_line[0]))
+ node.append(PropertyNode('hour', split_line[1]))
+ node.append(PropertyNode('day_of_month', split_line[2]))
+ node.append(PropertyNode('month', split_line[3]))
+ node.append(PropertyNode('day_of_week', split_line[4]))
+ node.append(PropertyNode('command', split_line[5]))
+ else:
+ continue
+ root.append(node)
+ comment = None
+ root.comment = comment
+ return root
+
+ def stringify(self, tree):
+ result_lines = []
+ stringify_func = {
+ 'special_task': self.stringify_special_task,
+ 'env_setting': self.stringify_env_setting,
+ 'normal_task': self.stringify_normal_task,
+ }
+ for node in tree:
+ if isinstance(node, Node):
+ string_line = stringify_func.get(node.name, lambda x: '')(node)
+ if node.comment:
+ result_lines.append('#' + node.comment)
+ result_lines.append(string_line)
+ return '\n'.join([line for line in result_lines if line])
+
+ def stringify_special_task(self, node):
+ special_node = node.get('special')
+ command_node = node.get('command')
+ if isinstance(special_node, PropertyNode) and isinstance(command_node, PropertyNode):
+ return ' '.join([special_node.value, command_node.value])
+ return ''
+
+ def stringify_env_setting(self, node):
+ name = node.get('name')
+ value = node.get('value')
+ if isinstance(name, PropertyNode) and isinstance(value, PropertyNode):
+ return ' = '.join([name.value, value.value])
+ return ''
+
+ def stringify_normal_task(self, node):
+ if all([isinstance(child, PropertyNode) for child in node.children]):
+ values_list = [str(pr_node.value).strip() for pr_node in node.children if pr_node.value]
+ if len(values_list) == 6:
+ return ' '.join(values_list)
+ return ''
diff --git a/reconfigure/parsers/exports.py b/reconfigure/parsers/exports.py
new file mode 100644
index 0000000..1941f61
--- /dev/null
+++ b/reconfigure/parsers/exports.py
@@ -0,0 +1,47 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+from reconfigure.parsers.ssv import SSVParser
+
+
+class ExportsParser (BaseParser):
+ """
+ A parser for NFS' /etc/exports
+ """
+
+ def __init__(self, *args, **kwargs):
+ BaseParser.__init__(self, *args, **kwargs)
+ self.inner = SSVParser(continuation='\\')
+
+ def parse(self, content):
+ tree = self.inner.parse(content)
+ root = RootNode()
+ for export in tree:
+ export_node = Node(export[0].get('value').value)
+ export_node.comment = export.comment
+ clients_node = Node('clients')
+ export_node.append(clients_node)
+ root.append(export_node)
+
+ for client in export[1:]:
+ s = client.get('value').value
+ name = s.split('(')[0]
+ options = ''
+ if '(' in s:
+ options = s.split('(', 1)[1].rstrip(')')
+ client_node = Node(name)
+ client_node.set_property('options', options)
+ clients_node.append(client_node)
+ return root
+
+ def stringify(self, tree):
+ root = RootNode()
+ for export in tree:
+ export_node = Node('line', comment=export.comment)
+ export_node.append(Node('token', PropertyNode('value', export.name)))
+ for client in export['clients']:
+ s = client.name
+ if client['options'].value:
+ s += '(%s)' % client['options'].value
+ export_node.append(Node('token', PropertyNode('value', s)))
+ root.append(export_node)
+ return self.inner.stringify(root)
diff --git a/reconfigure/parsers/ini.py b/reconfigure/parsers/ini.py
new file mode 100644
index 0000000..dbd0c7c
--- /dev/null
+++ b/reconfigure/parsers/ini.py
@@ -0,0 +1,70 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+from iniparse import INIConfig
+from StringIO import StringIO
+
+
+class IniFileParser (BaseParser):
+ """
+ A parser for standard ``.ini`` config files.
+
+ :param sectionless: if ``True``, allows a section-less attributes appear in the beginning of file
+ """
+
+ def __init__(self, sectionless=False, nullsection='__default__'):
+ self.sectionless = sectionless
+ self.nullsection = nullsection
+
+ def _get_comment(self, container):
+ c = container.contents[0].comment
+ return c.strip() if c else None
+
+ def _set_comment(self, container, comment):
+ if comment:
+ container.contents[0].comment = comment
+ container.contents[0].comment_separator = ';'
+
+ def parse(self, content):
+ content = '\n'.join(filter(None, [x.strip() for x in content.splitlines()]))
+ if self.sectionless:
+ content = '[' + self.nullsection + ']\n' + content
+ data = StringIO(content)
+ cp = INIConfig(data, optionxformvalue=lambda x: x)
+
+ root = RootNode()
+ for section in cp:
+ name = section
+ if self.sectionless and section == self.nullsection:
+ name = None
+ section_node = Node(name)
+ section_node.comment = self._get_comment(cp[section]._lines[0])
+ for option in cp[section]:
+ if option in cp[section]._options:
+ node = PropertyNode(option, cp[section][option])
+ node.comment = self._get_comment(cp[section]._options[option])
+ section_node.children.append(node)
+ root.children.append(section_node)
+ return root
+
+ def stringify(self, tree):
+ cp = INIConfig()
+
+ for section in tree.children:
+ if self.sectionless and section.name is None:
+ sectionname = self.nullsection
+ else:
+ sectionname = section.name
+ cp._new_namespace(sectionname)
+ for option in section.children:
+ if not isinstance(option, PropertyNode):
+ raise TypeError('Third level nodes should be PropertyNodes')
+ cp[sectionname][option.name] = option.value
+ if option.comment:
+ self._set_comment(cp[sectionname]._options[option.name], option.comment)
+ if hasattr(cp[sectionname], '_lines'):
+ self._set_comment(cp[sectionname]._lines[0], section.comment)
+
+ data = str(cp) + '\n'
+ if self.sectionless:
+ data = data.replace('[' + self.nullsection + ']\n', '')
+ return data
diff --git a/reconfigure/parsers/iniparse/__init__.py b/reconfigure/parsers/iniparse/__init__.py
new file mode 100644
index 0000000..618bd20
--- /dev/null
+++ b/reconfigure/parsers/iniparse/__init__.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2001, 2002, 2003 Python Software Foundation
+# Copyright (c) 2004-2008 Paramjit Oberoi <param.cs.wisc.edu>
+# Copyright (c) 2007 Tim Lauridsen <tla@rasmil.dk>
+# All Rights Reserved. See LICENSE-PSF & LICENSE for details.
+
+from ini import INIConfig, change_comment_syntax
+from config import BasicConfig, ConfigNamespace
+from compat import RawConfigParser, ConfigParser, SafeConfigParser
+from utils import tidy
+
+from ConfigParser import DuplicateSectionError, \
+ NoSectionError, NoOptionError, \
+ InterpolationMissingOptionError, \
+ InterpolationDepthError, \
+ InterpolationSyntaxError, \
+ DEFAULTSECT, MAX_INTERPOLATION_DEPTH
+
+__all__ = [
+ 'BasicConfig', 'ConfigNamespace',
+ 'INIConfig', 'tidy', 'change_comment_syntax',
+ 'RawConfigParser', 'ConfigParser', 'SafeConfigParser',
+ 'DuplicateSectionError', 'NoSectionError', 'NoOptionError',
+ 'InterpolationMissingOptionError', 'InterpolationDepthError',
+ 'InterpolationSyntaxError', 'DEFAULTSECT', 'MAX_INTERPOLATION_DEPTH',
+] \ No newline at end of file
diff --git a/reconfigure/parsers/iniparse/compat.py b/reconfigure/parsers/iniparse/compat.py
new file mode 100644
index 0000000..17c4f67
--- /dev/null
+++ b/reconfigure/parsers/iniparse/compat.py
@@ -0,0 +1,343 @@
+# Copyright (c) 2001, 2002, 2003 Python Software Foundation
+# Copyright (c) 2004-2008 Paramjit Oberoi <param.cs.wisc.edu>
+# All Rights Reserved. See LICENSE-PSF & LICENSE for details.
+
+"""Compatibility interfaces for ConfigParser
+
+Interfaces of ConfigParser, RawConfigParser and SafeConfigParser
+should be completely identical to the Python standard library
+versions. Tested with the unit tests included with Python-2.3.4
+
+The underlying INIConfig object can be accessed as cfg.data
+"""
+
+import re
+from ConfigParser import DuplicateSectionError, \
+ NoSectionError, NoOptionError, \
+ InterpolationMissingOptionError, \
+ InterpolationDepthError, \
+ InterpolationSyntaxError, \
+ DEFAULTSECT, MAX_INTERPOLATION_DEPTH
+
+# These are imported only for compatiability.
+# The code below does not reference them directly.
+from ConfigParser import Error, InterpolationError, \
+ MissingSectionHeaderError, ParsingError
+
+import ini
+
+class RawConfigParser(object):
+ def __init__(self, defaults=None, dict_type=dict):
+ if dict_type != dict:
+ raise ValueError('Custom dict types not supported')
+ self.data = ini.INIConfig(defaults=defaults, optionxformsource=self)
+
+ def optionxform(self, optionstr):
+ return optionstr.lower()
+
+ def defaults(self):
+ d = {}
+ secobj = self.data._defaults
+ for name in secobj._options:
+ d[name] = secobj._compat_get(name)
+ return d
+
+ def sections(self):
+ """Return a list of section names, excluding [DEFAULT]"""
+ return list(self.data)
+
+ def add_section(self, section):
+ """Create a new section in the configuration.
+
+ Raise DuplicateSectionError if a section by the specified name
+ already exists. Raise ValueError if name is DEFAULT or any of
+ its case-insensitive variants.
+ """
+ # The default section is the only one that gets the case-insensitive
+ # treatment - so it is special-cased here.
+ if section.lower() == "default":
+ raise ValueError, 'Invalid section name: %s' % section
+
+ if self.has_section(section):
+ raise DuplicateSectionError(section)
+ else:
+ self.data._new_namespace(section)
+
+ def has_section(self, section):
+ """Indicate whether the named section is present in the configuration.
+
+ The DEFAULT section is not acknowledged.
+ """
+ return (section in self.data)
+
+ def options(self, section):
+ """Return a list of option names for the given section name."""
+ if section in self.data:
+ return list(self.data[section])
+ else:
+ raise NoSectionError(section)
+
+ def read(self, filenames):
+ """Read and parse a filename or a list of filenames.
+
+ Files that cannot be opened are silently ignored; this is
+ designed so that you can specify a list of potential
+ configuration file locations (e.g. current directory, user's
+ home directory, systemwide directory), and all existing
+ configuration files in the list will be read. A single
+ filename may also be given.
+ """
+ files_read = []
+ if isinstance(filenames, basestring):
+ filenames = [filenames]
+ for filename in filenames:
+ try:
+ fp = open(filename)
+ except IOError:
+ continue
+ files_read.append(filename)
+ self.data._readfp(fp)
+ fp.close()
+ return files_read
+
+ def readfp(self, fp, filename=None):
+ """Like read() but the argument must be a file-like object.
+
+ The `fp' argument must have a `readline' method. Optional
+ second argument is the `filename', which if not given, is
+ taken from fp.name. If fp has no `name' attribute, `<???>' is
+ used.
+ """
+ self.data._readfp(fp)
+
+ def get(self, section, option, vars=None):
+ if not self.has_section(section):
+ raise NoSectionError(section)
+ if vars is not None and option in vars:
+ value = vars[option]
+
+ sec = self.data[section]
+ if option in sec:
+ return sec._compat_get(option)
+ else:
+ raise NoOptionError(option, section)
+
+ def items(self, section):
+ if section in self.data:
+ ans = []
+ for opt in self.data[section]:
+ ans.append((opt, self.get(section, opt)))
+ return ans
+ else:
+ raise NoSectionError(section)
+
+ def getint(self, section, option):
+ return int(self.get(section, option))
+
+ def getfloat(self, section, option):
+ return float(self.get(section, option))
+
+ _boolean_states = {'1': True, 'yes': True, 'true': True, 'on': True,
+ '0': False, 'no': False, 'false': False, 'off': False}
+
+ def getboolean(self, section, option):
+ v = self.get(section, option)
+ if v.lower() not in self._boolean_states:
+ raise ValueError, 'Not a boolean: %s' % v
+ return self._boolean_states[v.lower()]
+
+ def has_option(self, section, option):
+ """Check for the existence of a given option in a given section."""
+ if section in self.data:
+ sec = self.data[section]
+ else:
+ raise NoSectionError(section)
+ return (option in sec)
+
+ def set(self, section, option, value):
+ """Set an option."""
+ if section in self.data:
+ self.data[section][option] = value
+ else:
+ raise NoSectionError(section)
+
+ def write(self, fp):
+ """Write an .ini-format representation of the configuration state."""
+ fp.write(str(self.data))
+
+ def remove_option(self, section, option):
+ """Remove an option."""
+ if section in self.data:
+ sec = self.data[section]
+ else:
+ raise NoSectionError(section)
+ if option in sec:
+ del sec[option]
+ return 1
+ else:
+ return 0
+
+ def remove_section(self, section):
+ """Remove a file section."""
+ if not self.has_section(section):
+ return False
+ del self.data[section]
+ return True
+
+
+class ConfigDict(object):
+ """Present a dict interface to a ini section."""
+
+ def __init__(self, cfg, section, vars):
+ self.cfg = cfg
+ self.section = section
+ self.vars = vars
+
+ def __getitem__(self, key):
+ try:
+ return RawConfigParser.get(self.cfg, self.section, key, self.vars)
+ except (NoOptionError, NoSectionError):
+ raise KeyError(key)
+
+
+class ConfigParser(RawConfigParser):
+
+ def get(self, section, option, raw=False, vars=None):
+ """Get an option value for a given section.
+
+ All % interpolations are expanded in the return values, based on the
+ defaults passed into the constructor, unless the optional argument
+ `raw' is true. Additional substitutions may be provided using the
+ `vars' argument, which must be a dictionary whose contents overrides
+ any pre-existing defaults.
+
+ The section DEFAULT is special.
+ """
+ if section != DEFAULTSECT and not self.has_section(section):
+ raise NoSectionError(section)
+
+ option = self.optionxform(option)
+ value = RawConfigParser.get(self, section, option, vars)
+
+ if raw:
+ return value
+ else:
+ d = ConfigDict(self, section, vars)
+ return self._interpolate(section, option, value, d)
+
+ def _interpolate(self, section, option, rawval, vars):
+ # do the string interpolation
+ value = rawval
+ depth = MAX_INTERPOLATION_DEPTH
+ while depth: # Loop through this until it's done
+ depth -= 1
+ if "%(" in value:
+ try:
+ value = value % vars
+ except KeyError, e:
+ raise InterpolationMissingOptionError(
+ option, section, rawval, e.args[0])
+ else:
+ break
+ if value.find("%(") != -1:
+ raise InterpolationDepthError(option, section, rawval)
+ return value
+
+ def items(self, section, raw=False, vars=None):
+ """Return a list of tuples with (name, value) for each option
+ in the section.
+
+ All % interpolations are expanded in the return values, based on the
+ defaults passed into the constructor, unless the optional argument
+ `raw' is true. Additional substitutions may be provided using the
+ `vars' argument, which must be a dictionary whose contents overrides
+ any pre-existing defaults.
+
+ The section DEFAULT is special.
+ """
+ if section != DEFAULTSECT and not self.has_section(section):
+ raise NoSectionError(section)
+ if vars is None:
+ options = list(self.data[section])
+ else:
+ options = []
+ for x in self.data[section]:
+ if x not in vars:
+ options.append(x)
+ options.extend(vars.keys())
+
+ if "__name__" in options:
+ options.remove("__name__")
+
+ d = ConfigDict(self, section, vars)
+ if raw:
+ return [(option, d[option])
+ for option in options]
+ else:
+ return [(option, self._interpolate(section, option, d[option], d))
+ for option in options]
+
+
+class SafeConfigParser(ConfigParser):
+ _interpvar_re = re.compile(r"%\(([^)]+)\)s")
+ _badpercent_re = re.compile(r"%[^%]|%$")
+
+ def set(self, section, option, value):
+ if not isinstance(value, basestring):
+ raise TypeError("option values must be strings")
+ # check for bad percent signs:
+ # first, replace all "good" interpolations
+ tmp_value = self._interpvar_re.sub('', value)
+ # then, check if there's a lone percent sign left
+ m = self._badpercent_re.search(tmp_value)
+ if m:
+ raise ValueError("invalid interpolation syntax in %r at "
+ "position %d" % (value, m.start()))
+
+ ConfigParser.set(self, section, option, value)
+
+ def _interpolate(self, section, option, rawval, vars):
+ # do the string interpolation
+ L = []
+ self._interpolate_some(option, L, rawval, section, vars, 1)
+ return ''.join(L)
+
+ _interpvar_match = re.compile(r"%\(([^)]+)\)s").match
+
+ def _interpolate_some(self, option, accum, rest, section, map, depth):
+ if depth > MAX_INTERPOLATION_DEPTH:
+ raise InterpolationDepthError(option, section, rest)
+ while rest:
+ p = rest.find("%")
+ if p < 0:
+ accum.append(rest)
+ return
+ if p > 0:
+ accum.append(rest[:p])
+ rest = rest[p:]
+ # p is no longer used
+ c = rest[1:2]
+ if c == "%":
+ accum.append("%")
+ rest = rest[2:]
+ elif c == "(":
+ m = self._interpvar_match(rest)
+ if m is None:
+ raise InterpolationSyntaxError(option, section,
+ "bad interpolation variable reference %r" % rest)
+ var = m.group(1)
+ rest = rest[m.end():]
+ try:
+ v = map[var]
+ except KeyError:
+ raise InterpolationMissingOptionError(
+ option, section, rest, var)
+ if "%" in v:
+ self._interpolate_some(option, accum, v,
+ section, map, depth + 1)
+ else:
+ accum.append(v)
+ else:
+ raise InterpolationSyntaxError(
+ option, section,
+ "'%' must be followed by '%' or '(', found: " + repr(rest)) \ No newline at end of file
diff --git a/reconfigure/parsers/iniparse/config.py b/reconfigure/parsers/iniparse/config.py
new file mode 100644
index 0000000..d007f16
--- /dev/null
+++ b/reconfigure/parsers/iniparse/config.py
@@ -0,0 +1,293 @@
+class ConfigNamespace(object):
+ """Abstract class representing the interface of Config objects.
+
+ A ConfigNamespace is a collection of names mapped to values, where
+ the values may be nested namespaces. Values can be accessed via
+ container notation - obj[key] - or via dotted notation - obj.key.
+ Both these access methods are equivalent.
+
+ To minimize name conflicts between namespace keys and class members,
+ the number of class members should be minimized, and the names of
+ all class members should start with an underscore.
+
+ Subclasses must implement the methods for container-like access,
+ and this class will automatically provide dotted access.
+
+ """
+
+ # Methods that must be implemented by subclasses
+
+ def _getitem(self, key):
+ return NotImplementedError(key)
+
+ def __setitem__(self, key, value):
+ raise NotImplementedError(key, value)
+
+ def __delitem__(self, key):
+ raise NotImplementedError(key)
+
+ def __iter__(self):
+ return NotImplementedError()
+
+ def _new_namespace(self, name):
+ raise NotImplementedError(name)
+
+ def __contains__(self, key):
+ try:
+ self._getitem(key)
+ except KeyError:
+ return False
+ return True
+
+ # Machinery for converting dotted access into container access,
+ # and automatically creating new sections/namespaces.
+ #
+ # To distinguish between accesses of class members and namespace
+ # keys, we first call object.__getattribute__(). If that succeeds,
+ # the name is assumed to be a class member. Otherwise it is
+ # treated as a namespace key.
+ #
+ # Therefore, member variables should be defined in the class,
+ # not just in the __init__() function. See BasicNamespace for
+ # an example.
+
+ def __getitem__(self, key):
+ try:
+ return self._getitem(key)
+ except KeyError:
+ return Undefined(key, self)
+
+ def __getattr__(self, name):
+ try:
+ return self._getitem(name)
+ except KeyError:
+ if name.startswith('__') and name.endswith('__'):
+ raise AttributeError
+ return Undefined(name, self)
+
+ def __setattr__(self, name, value):
+ try:
+ object.__getattribute__(self, name)
+ object.__setattr__(self, name, value)
+ except AttributeError:
+ self.__setitem__(name, value)
+
+ def __delattr__(self, name):
+ try:
+ object.__getattribute__(self, name)
+ object.__delattr__(self, name)
+ except AttributeError:
+ self.__delitem__(name)
+
+ # During unpickling, Python checks if the class has a __setstate__
+ # method. But, the data dicts have not been initialised yet, which
+ # leads to _getitem and hence __getattr__ raising an exception. So
+ # we explicitly impement default __setstate__ behavior.
+ def __setstate__(self, state):
+ self.__dict__.update(state)
+
+class Undefined(object):
+ """Helper class used to hold undefined names until assignment.
+
+ This class helps create any undefined subsections when an
+ assignment is made to a nested value. For example, if the
+ statement is "cfg.a.b.c = 42", but "cfg.a.b" does not exist yet.
+ """
+
+ def __init__(self, name, namespace):
+ object.__setattr__(self, 'name', name)
+ object.__setattr__(self, 'namespace', namespace)
+
+ def __setattr__(self, name, value):
+ obj = self.namespace._new_namespace(self.name)
+ obj[name] = value
+
+ def __setitem__(self, name, value):
+ obj = self.namespace._new_namespace(self.name)
+ obj[name] = value
+
+
+# ---- Basic implementation of a ConfigNamespace
+
+class BasicConfig(ConfigNamespace):
+ """Represents a hierarchical collection of named values.
+
+ Values are added using dotted notation:
+
+ >>> n = BasicConfig()
+ >>> n.x = 7
+ >>> n.name.first = 'paramjit'
+ >>> n.name.last = 'oberoi'
+
+ ...and accessed the same way, or with [...]:
+
+ >>> n.x
+ 7
+ >>> n.name.first
+ 'paramjit'
+ >>> n.name.last
+ 'oberoi'
+ >>> n['x']
+ 7
+ >>> n['name']['first']
+ 'paramjit'
+
+ Iterating over the namespace object returns the keys:
+
+ >>> l = list(n)
+ >>> l.sort()
+ >>> l
+ ['name', 'x']
+
+ Values can be deleted using 'del' and printed using 'print'.
+
+ >>> n.aaa = 42
+ >>> del n.x
+ >>> print n
+ aaa = 42
+ name.first = paramjit
+ name.last = oberoi
+
+ Nested namepsaces are also namespaces:
+
+ >>> isinstance(n.name, ConfigNamespace)
+ True
+ >>> print n.name
+ first = paramjit
+ last = oberoi
+ >>> sorted(list(n.name))
+ ['first', 'last']
+
+ Finally, values can be read from a file as follows:
+
+ >>> from StringIO import StringIO
+ >>> sio = StringIO('''
+ ... # comment
+ ... ui.height = 100
+ ... ui.width = 150
+ ... complexity = medium
+ ... have_python
+ ... data.secret.password = goodness=gracious me
+ ... ''')
+ >>> n = BasicConfig()
+ >>> n._readfp(sio)
+ >>> print n
+ complexity = medium
+ data.secret.password = goodness=gracious me
+ have_python
+ ui.height = 100
+ ui.width = 150
+ """
+
+ # this makes sure that __setattr__ knows this is not a namespace key
+ _data = None
+
+ def __init__(self):
+ self._data = {}
+
+ def _getitem(self, key):
+ return self._data[key]
+
+ def __setitem__(self, key, value):
+ self._data[key] = value
+
+ def __delitem__(self, key):
+ del self._data[key]
+
+ def __iter__(self):
+ return iter(self._data)
+
+ def __str__(self, prefix=''):
+ lines = []
+ keys = self._data.keys()
+ keys.sort()
+ for name in keys:
+ value = self._data[name]
+ if isinstance(value, ConfigNamespace):
+ lines.append(value.__str__(prefix='%s%s.' % (prefix,name)))
+ else:
+ if value is None:
+ lines.append('%s%s' % (prefix, name))
+ else:
+ lines.append('%s%s = %s' % (prefix, name, value))
+ return '\n'.join(lines)
+
+ def _new_namespace(self, name):
+ obj = BasicConfig()
+ self._data[name] = obj
+ return obj
+
+ def _readfp(self, fp):
+ while True:
+ line = fp.readline()
+ if not line:
+ break
+
+ line = line.strip()
+ if not line: continue
+ if line[0] == '#': continue
+ data = line.split('=', 1)
+ if len(data) == 1:
+ name = line
+ value = None
+ else:
+ name = data[0].strip()
+ value = data[1].strip()
+ name_components = name.split('.')
+ ns = self
+ for n in name_components[:-1]:
+ if n in ns:
+ ns = ns[n]
+ if not isinstance(ns, ConfigNamespace):
+ raise TypeError('value-namespace conflict', n)
+ else:
+ ns = ns._new_namespace(n)
+ ns[name_components[-1]] = value
+
+
+# ---- Utility functions
+
+def update_config(target, source):
+ """Imports values from source into target.
+
+ Recursively walks the <source> ConfigNamespace and inserts values
+ into the <target> ConfigNamespace. For example:
+
+ >>> n = BasicConfig()
+ >>> n.playlist.expand_playlist = True
+ >>> n.ui.display_clock = True
+ >>> n.ui.display_qlength = True
+ >>> n.ui.width = 150
+ >>> print n
+ playlist.expand_playlist = True
+ ui.display_clock = True
+ ui.display_qlength = True
+ ui.width = 150
+
+ >>> from iniparse import ini
+ >>> i = ini.INIConfig()
+ >>> update_config(i, n)
+ >>> print i
+ [playlist]
+ expand_playlist = True
+ <BLANKLINE>
+ [ui]
+ display_clock = True
+ display_qlength = True
+ width = 150
+
+ """
+ for name in source:
+ value = source[name]
+ if isinstance(value, ConfigNamespace):
+ if name in target:
+ myns = target[name]
+ if not isinstance(myns, ConfigNamespace):
+ raise TypeError('value-namespace conflict')
+ else:
+ myns = target._new_namespace(name)
+ update_config(myns, value)
+ else:
+ target[name] = value
+
+
diff --git a/reconfigure/parsers/iniparse/ini.py b/reconfigure/parsers/iniparse/ini.py
new file mode 100644
index 0000000..7881fd2
--- /dev/null
+++ b/reconfigure/parsers/iniparse/ini.py
@@ -0,0 +1,642 @@
+"""Access and/or modify INI files
+
+* Compatiable with ConfigParser
+* Preserves order of sections & options
+* Preserves comments/blank lines/etc
+* More conveninet access to data
+
+Example:
+
+ >>> from StringIO import StringIO
+ >>> sio = StringIO('''# configure foo-application
+ ... [foo]
+ ... bar1 = qualia
+ ... bar2 = 1977
+ ... [foo-ext]
+ ... special = 1''')
+
+ >>> cfg = INIConfig(sio)
+ >>> print cfg.foo.bar1
+ qualia
+ >>> print cfg['foo-ext'].special
+ 1
+ >>> cfg.foo.newopt = 'hi!'
+ >>> cfg.baz.enabled = 0
+
+ >>> print cfg
+ # configure foo-application
+ [foo]
+ bar1 = qualia
+ bar2 = 1977
+ newopt = hi!
+ [foo-ext]
+ special = 1
+ <BLANKLINE>
+ [baz]
+ enabled = 0
+
+"""
+
+# An ini parser that supports ordered sections/options
+# Also supports updates, while preserving structure
+# Backward-compatiable with ConfigParser
+
+import re
+from ConfigParser import DEFAULTSECT, ParsingError, MissingSectionHeaderError
+
+import config
+
+class LineType(object):
+ line = None
+
+ def __init__(self, line=None):
+ if line is not None:
+ self.line = line.strip('\n')
+
+ # Return the original line for unmodified objects
+ # Otherwise construct using the current attribute values
+ def __str__(self):
+ if self.line is not None:
+ return self.line
+ else:
+ return self.to_string()
+
+ # If an attribute is modified after initialization
+ # set line to None since it is no longer accurate.
+ def __setattr__(self, name, value):
+ if hasattr(self,name):
+ self.__dict__['line'] = None
+ self.__dict__[name] = value
+
+ def to_string(self):
+ raise Exception('This method must be overridden in derived classes')
+
+
+class SectionLine(LineType):
+ regex = re.compile(r'^\['
+ r'(?P<name>[^]]+)'
+ r'\]\s*'
+ r'((?P<csep>;|#)(?P<comment>.*))?$')
+
+ def __init__(self, name, comment=None, comment_separator=None,
+ comment_offset=-1, line=None):
+ super(SectionLine, self).__init__(line)
+ self.name = name
+ self.comment = comment
+ self.comment_separator = comment_separator
+ self.comment_offset = comment_offset
+
+ def to_string(self):
+ out = '[' + self.name + ']'
+ if self.comment is not None:
+ # try to preserve indentation of comments
+ out = (out+' ').ljust(self.comment_offset)
+ out = out + self.comment_separator + self.comment
+ return out
+
+ def parse(cls, line):
+ m = cls.regex.match(line.rstrip())
+ if m is None:
+ return None
+ return cls(m.group('name'), m.group('comment'),
+ m.group('csep'), m.start('csep'),
+ line)
+ parse = classmethod(parse)
+
+
+class OptionLine(LineType):
+ def __init__(self, name, value, separator='=', comment=None,
+ comment_separator=None, comment_offset=-1, line=None):
+ super(OptionLine, self).__init__(line)
+ self.name = name
+ self.value = value
+ self.separator = separator
+ self.comment = comment
+ self.comment_separator = comment_separator
+ self.comment_offset = comment_offset
+
+ def to_string(self):
+ out = '%s%s%s' % (self.name, self.separator, self.value)
+ if self.comment is not None:
+ # try to preserve indentation of comments
+ out = (out+' ').ljust(self.comment_offset)
+ out = out + self.comment_separator + self.comment
+ return out
+
+ regex = re.compile(r'^(?P<name>[^:=\s[][^:=]*)'
+ r'(?P<sep>[:=]\s*)'
+ r'(?P<value>.*)$')
+
+ def parse(cls, line):
+ m = cls.regex.match(line.rstrip())
+ if m is None:
+ return None
+
+ name = m.group('name').rstrip()
+ value = m.group('value')
+ sep = m.group('name')[len(name):] + m.group('sep')
+
+ # comments are not detected in the regex because
+ # ensuring total compatibility with ConfigParser
+ # requires that:
+ # option = value ;comment // value=='value'
+ # option = value;1 ;comment // value=='value;1 ;comment'
+ #
+ # Doing this in a regex would be complicated. I
+ # think this is a bug. The whole issue of how to
+ # include ';' in the value needs to be addressed.
+ # Also, '#' doesn't mark comments in options...
+
+ coff = value.find(';')
+ if coff != -1 and value[coff-1].isspace():
+ comment = value[coff+1:]
+ csep = value[coff]
+ value = value[:coff].rstrip()
+ coff = m.start('value') + coff
+ else:
+ comment = None
+ csep = None
+ coff = -1
+
+ return cls(name, value, sep, comment, csep, coff, line)
+ parse = classmethod(parse)
+
+
+def change_comment_syntax(comment_chars='%;#', allow_rem=False):
+ comment_chars = re.sub(r'([\]\-\^])', r'\\\1', comment_chars)
+ regex = r'^(?P<csep>[%s]' % comment_chars
+ if allow_rem:
+ regex += '|[rR][eE][mM]'
+ regex += r')(?P<comment>.*)$'
+ CommentLine.regex = re.compile(regex)
+
+class CommentLine(LineType):
+ regex = re.compile(r'^(?P<csep>[;#]|[rR][eE][mM])'
+ r'(?P<comment>.*)$')
+
+ def __init__(self, comment='', separator='#', line=None):
+ super(CommentLine, self).__init__(line)
+ self.comment = comment
+ self.separator = separator
+
+ def to_string(self):
+ return self.separator + self.comment
+
+ def parse(cls, line):
+ m = cls.regex.match(line.rstrip())
+ if m is None:
+ return None
+ return cls(m.group('comment'), m.group('csep'), line)
+ parse = classmethod(parse)
+
+
+class EmptyLine(LineType):
+ # could make this a singleton
+ def to_string(self):
+ return ''
+
+ value = property(lambda _: '')
+
+ def parse(cls, line):
+ if line.strip(): return None
+ return cls(line)
+ parse = classmethod(parse)
+
+
+class ContinuationLine(LineType):
+ regex = re.compile(r'^\s+(?P<value>.*)$')
+
+ def __init__(self, value, value_offset=None, line=None):
+ super(ContinuationLine, self).__init__(line)
+ self.value = value
+ if value_offset is None:
+ value_offset = 8
+ self.value_offset = value_offset
+
+ def to_string(self):
+ return ' '*self.value_offset + self.value
+
+ def parse(cls, line):
+ m = cls.regex.match(line.rstrip())
+ if m is None:
+ return None
+ return cls(m.group('value'), m.start('value'), line)
+ parse = classmethod(parse)
+
+
+class LineContainer(object):
+ def __init__(self, d=None):
+ self.contents = []
+ self.orgvalue = None
+ if d:
+ if isinstance(d, list): self.extend(d)
+ else: self.add(d)
+
+ def add(self, x):
+ self.contents.append(x)
+
+ def extend(self, x):
+ for i in x: self.add(i)
+
+ def get_name(self):
+ return self.contents[0].name
+
+ def set_name(self, data):
+ self.contents[0].name = data
+
+ def get_value(self):
+ if self.orgvalue is not None:
+ return self.orgvalue
+ elif len(self.contents) == 1:
+ return self.contents[0].value
+ else:
+ return '\n'.join([('%s' % x.value) for x in self.contents
+ if not isinstance(x, CommentLine)])
+
+ def set_value(self, data):
+ self.orgvalue = data
+ lines = ('%s' % data).split('\n')
+
+ # If there is an existing ContinuationLine, use its offset
+ value_offset = None
+ for v in self.contents:
+ if isinstance(v, ContinuationLine):
+ value_offset = v.value_offset
+ break
+
+ # Rebuild contents list, preserving initial OptionLine
+ self.contents = self.contents[0:1]
+ self.contents[0].value = lines[0]
+ del lines[0]
+ for line in lines:
+ if line.strip():
+ self.add(ContinuationLine(line, value_offset))
+ else:
+ self.add(EmptyLine())
+
+ name = property(get_name, set_name)
+ value = property(get_value, set_value)
+
+ def __str__(self):
+ s = [x.__str__() for x in self.contents]
+ return '\n'.join(s)
+
+ def finditer(self, key):
+ for x in self.contents[::-1]:
+ if hasattr(x, 'name') and x.name==key:
+ yield x
+
+ def find(self, key):
+ for x in self.finditer(key):
+ return x
+ raise KeyError(key)
+
+
+def _make_xform_property(myattrname, srcattrname=None):
+ private_attrname = myattrname + 'value'
+ private_srcname = myattrname + 'source'
+ if srcattrname is None:
+ srcattrname = myattrname
+
+ def getfn(self):
+ srcobj = getattr(self, private_srcname)
+ if srcobj is not None:
+ return getattr(srcobj, srcattrname)
+ else:
+ return getattr(self, private_attrname)
+
+ def setfn(self, value):
+ srcobj = getattr(self, private_srcname)
+ if srcobj is not None:
+ setattr(srcobj, srcattrname, value)
+ else:
+ setattr(self, private_attrname, value)
+
+ return property(getfn, setfn)
+
+
+class INISection(config.ConfigNamespace):
+ _lines = None
+ _options = None
+ _defaults = None
+ _optionxformvalue = None
+ _optionxformsource = None
+ _compat_skip_empty_lines = set()
+ def __init__(self, lineobj, defaults = None,
+ optionxformvalue=None, optionxformsource=None):
+ self._lines = [lineobj]
+ self._defaults = defaults
+ self._optionxformvalue = optionxformvalue
+ self._optionxformsource = optionxformsource
+ self._options = {}
+
+ _optionxform = _make_xform_property('_optionxform')
+
+ def _compat_get(self, key):
+ # identical to __getitem__ except that _compat_XXX
+ # is checked for backward-compatible handling
+ if key == '__name__':
+ return self._lines[-1].name
+ if self._optionxform: key = self._optionxform(key)
+ try:
+ value = self._options[key].value
+ del_empty = key in self._compat_skip_empty_lines
+ except KeyError:
+ if self._defaults and key in self._defaults._options:
+ value = self._defaults._options[key].value
+ del_empty = key in self._defaults._compat_skip_empty_lines
+ else:
+ raise
+ if del_empty:
+ value = re.sub('\n+', '\n', value)
+ return value
+
+ def _getitem(self, key):
+ if key == '__name__':
+ return self._lines[-1].name
+ if self._optionxform: key = self._optionxform(key)
+ try:
+ return self._options[key].value
+ except KeyError:
+ if self._defaults and key in self._defaults._options:
+ return self._defaults._options[key].value
+ else:
+ raise
+
+ def __setitem__(self, key, value):
+ if self._optionxform: xkey = self._optionxform(key)
+ else: xkey = key
+ if xkey in self._compat_skip_empty_lines:
+ self._compat_skip_empty_lines.remove(xkey)
+ if xkey not in self._options:
+ # create a dummy object - value may have multiple lines
+ obj = LineContainer(OptionLine(key, ''))
+ self._lines[-1].add(obj)
+ self._options[xkey] = obj
+ # the set_value() function in LineContainer
+ # automatically handles multi-line values
+ self._options[xkey].value = value
+
+ def __delitem__(self, key):
+ if self._optionxform: key = self._optionxform(key)
+ if key in self._compat_skip_empty_lines:
+ self._compat_skip_empty_lines.remove(key)
+ for l in self._lines:
+ remaining = []
+ for o in l.contents:
+ if isinstance(o, LineContainer):
+ n = o.name
+ if self._optionxform: n = self._optionxform(n)
+ if key != n: remaining.append(o)
+ else:
+ remaining.append(o)
+ l.contents = remaining
+ del self._options[key]
+
+ def __iter__(self):
+ d = set()
+ for l in self._lines:
+ for x in l.contents:
+ if isinstance(x, LineContainer):
+ if self._optionxform:
+ ans = self._optionxform(x.name)
+ else:
+ ans = x.name
+ if ans not in d:
+ yield ans
+ d.add(ans)
+ if self._defaults:
+ for x in self._defaults:
+ if x not in d:
+ yield x
+ d.add(x)
+
+ def _new_namespace(self, name):
+ raise Exception('No sub-sections allowed', name)
+
+
+def make_comment(line):
+ return CommentLine(line.rstrip('\n'))
+
+
+def readline_iterator(f):
+ """iterate over a file by only using the file object's readline method"""
+
+ have_newline = False
+ while True:
+ line = f.readline()
+
+ if not line:
+ if have_newline:
+ yield ""
+ return
+
+ if line.endswith('\n'):
+ have_newline = True
+ else:
+ have_newline = False
+
+ yield line
+
+
+def lower(x):
+ return x.lower()
+
+
+class INIConfig(config.ConfigNamespace):
+ _data = None
+ _sections = None
+ _defaults = None
+ _optionxformvalue = None
+ _optionxformsource = None
+ _sectionxformvalue = None
+ _sectionxformsource = None
+ _parse_exc = None
+ _bom = False
+ def __init__(self, fp=None, defaults=None, parse_exc=True,
+ optionxformvalue=lower, optionxformsource=None,
+ sectionxformvalue=None, sectionxformsource=None):
+ self._data = LineContainer()
+ self._parse_exc = parse_exc
+ self._optionxformvalue = optionxformvalue
+ self._optionxformsource = optionxformsource
+ self._sectionxformvalue = sectionxformvalue
+ self._sectionxformsource = sectionxformsource
+ self._sections = {}
+ if defaults is None: defaults = {}
+ self._defaults = INISection(LineContainer(), optionxformsource=self)
+ for name, value in defaults.iteritems():
+ self._defaults[name] = value
+ if fp is not None:
+ self._readfp(fp)
+
+ _optionxform = _make_xform_property('_optionxform', 'optionxform')
+ _sectionxform = _make_xform_property('_sectionxform', 'optionxform')
+
+ def _getitem(self, key):
+ if key == DEFAULTSECT:
+ return self._defaults
+ if self._sectionxform: key = self._sectionxform(key)
+ return self._sections[key]
+
+ def __setitem__(self, key, value):
+ raise Exception('Values must be inside sections', key, value)
+
+ def __delitem__(self, key):
+ if self._sectionxform: key = self._sectionxform(key)
+ for line in self._sections[key]._lines:
+ self._data.contents.remove(line)
+ del self._sections[key]
+
+ def __iter__(self):
+ d = set()
+ d.add(DEFAULTSECT)
+ for x in self._data.contents:
+ if isinstance(x, LineContainer):
+ if x.name not in d:
+ yield x.name
+ d.add(x.name)
+
+ def _new_namespace(self, name):
+ if self._data.contents:
+ self._data.add(EmptyLine())
+ obj = LineContainer(SectionLine(name))
+ self._data.add(obj)
+ if self._sectionxform: name = self._sectionxform(name)
+ if name in self._sections:
+ ns = self._sections[name]
+ ns._lines.append(obj)
+ else:
+ ns = INISection(obj, defaults=self._defaults,
+ optionxformsource=self)
+ self._sections[name] = ns
+ return ns
+
+ def __str__(self):
+ if self._bom:
+ fmt = u'\ufeff%s'
+ else:
+ fmt = '%s'
+ return fmt % self._data.__str__()
+
+ __unicode__ = __str__
+
+ _line_types = [EmptyLine, CommentLine,
+ SectionLine, OptionLine,
+ ContinuationLine]
+
+ def _parse(self, line):
+ for linetype in self._line_types:
+ lineobj = linetype.parse(line)
+ if lineobj:
+ return lineobj
+ else:
+ # can't parse line
+ return None
+
+ def _readfp(self, fp):
+ cur_section = None
+ cur_option = None
+ cur_section_name = None
+ cur_option_name = None
+ pending_lines = []
+ pending_empty_lines = False
+ try:
+ fname = fp.name
+ except AttributeError:
+ fname = '<???>'
+ linecount = 0
+ exc = None
+ line = None
+
+ for line in readline_iterator(fp):
+ # Check for BOM on first line
+ if linecount == 0 and isinstance(line, unicode):
+ if line[0] == u'\ufeff':
+ line = line[1:]
+ self._bom = True
+
+ lineobj = self._parse(line)
+ linecount += 1
+
+ if not cur_section and not isinstance(lineobj,
+ (CommentLine, EmptyLine, SectionLine)):
+ if self._parse_exc:
+ raise MissingSectionHeaderError(fname, linecount, line)
+ else:
+ lineobj = make_comment(line)
+
+ if lineobj is None:
+ if self._parse_exc:
+ if exc is None: exc = ParsingError(fname)
+ exc.append(linecount, line)
+ lineobj = make_comment(line)
+
+ if isinstance(lineobj, ContinuationLine):
+ if cur_option:
+ if pending_lines:
+ cur_option.extend(pending_lines)
+ pending_lines = []
+ if pending_empty_lines:
+ optobj._compat_skip_empty_lines.add(cur_option_name)
+ pending_empty_lines = False
+ cur_option.add(lineobj)
+ else:
+ # illegal continuation line - convert to comment
+ if self._parse_exc:
+ if exc is None: exc = ParsingError(fname)
+ exc.append(linecount, line)
+ lineobj = make_comment(line)
+
+ if isinstance(lineobj, OptionLine):
+ if pending_lines:
+ cur_section.extend(pending_lines)
+ pending_lines = []
+ pending_empty_lines = False
+ cur_option = LineContainer(lineobj)
+ cur_section.add(cur_option)
+ if self._optionxform:
+ cur_option_name = self._optionxform(cur_option.name)
+ else:
+ cur_option_name = cur_option.name
+ if cur_section_name == DEFAULTSECT:
+ optobj = self._defaults
+ else:
+ optobj = self._sections[cur_section_name]
+ optobj._options[cur_option_name] = cur_option
+
+ if isinstance(lineobj, SectionLine):
+ self._data.extend(pending_lines)
+ pending_lines = []
+ pending_empty_lines = False
+ cur_section = LineContainer(lineobj)
+ self._data.add(cur_section)
+ cur_option = None
+ cur_option_name = None
+ if cur_section.name == DEFAULTSECT:
+ self._defaults._lines.append(cur_section)
+ cur_section_name = DEFAULTSECT
+ else:
+ if self._sectionxform:
+ cur_section_name = self._sectionxform(cur_section.name)
+ else:
+ cur_section_name = cur_section.name
+ if cur_section_name not in self._sections:
+ self._sections[cur_section_name] = \
+ INISection(cur_section, defaults=self._defaults,
+ optionxformsource=self)
+ else:
+ self._sections[cur_section_name]._lines.append(cur_section)
+
+ if isinstance(lineobj, (CommentLine, EmptyLine)):
+ pending_lines.append(lineobj)
+ if isinstance(lineobj, EmptyLine):
+ pending_empty_lines = True
+
+ self._data.extend(pending_lines)
+ if line and line[-1]=='\n':
+ self._data.add(EmptyLine())
+
+ if exc:
+ raise exc
+
diff --git a/reconfigure/parsers/iniparse/utils.py b/reconfigure/parsers/iniparse/utils.py
new file mode 100644
index 0000000..9cb7488
--- /dev/null
+++ b/reconfigure/parsers/iniparse/utils.py
@@ -0,0 +1,47 @@
+import compat
+from ini import LineContainer, EmptyLine
+
+def tidy(cfg):
+ """Clean up blank lines.
+
+ This functions makes the configuration look clean and
+ handwritten - consecutive empty lines and empty lines at
+ the start of the file are removed, and one is guaranteed
+ to be at the end of the file.
+ """
+
+ if isinstance(cfg, compat.RawConfigParser):
+ cfg = cfg.data
+ cont = cfg._data.contents
+ i = 1
+ while i < len(cont):
+ if isinstance(cont[i], LineContainer):
+ tidy_section(cont[i])
+ i += 1
+ elif (isinstance(cont[i-1], EmptyLine) and
+ isinstance(cont[i], EmptyLine)):
+ del cont[i]
+ else:
+ i += 1
+
+ # Remove empty first line
+ if cont and isinstance(cont[0], EmptyLine):
+ del cont[0]
+
+ # Ensure a last line
+ if cont and not isinstance(cont[-1], EmptyLine):
+ cont.append(EmptyLine())
+
+def tidy_section(lc):
+ cont = lc.contents
+ i = 1
+ while i < len(cont):
+ if (isinstance(cont[i-1], EmptyLine) and
+ isinstance(cont[i], EmptyLine)):
+ del cont[i]
+ else:
+ i += 1
+
+ # Remove empty first line
+ if len(cont) > 1 and isinstance(cont[1], EmptyLine):
+ del cont[1] \ No newline at end of file
diff --git a/reconfigure/parsers/iptables.py b/reconfigure/parsers/iptables.py
new file mode 100644
index 0000000..46edf60
--- /dev/null
+++ b/reconfigure/parsers/iptables.py
@@ -0,0 +1,72 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+
+
+class IPTablesParser (BaseParser):
+ """
+ A parser for ``iptables`` configuration as produced by ``iptables-save``
+ """
+
+ def parse(self, content):
+ content = filter(None, [x.strip() for x in content.splitlines() if not x.startswith('#')])
+ root = RootNode()
+ cur_table = None
+ chains = {}
+ for l in content:
+ if l.startswith('*'):
+ cur_table = Node(l[1:])
+ chains = {}
+ root.append(cur_table)
+ elif l.startswith(':'):
+ name = l[1:].split()[0]
+ node = Node(name)
+ node.set_property('default', l.split()[1])
+ chains[name] = node
+ cur_table.append(node)
+ else:
+ comment = None
+ if '#' in l:
+ l, comment = l.split('#')
+ comment = comment.strip()
+ tokens = l.split()
+ if tokens[0] == '-A':
+ tokens.pop(0)
+ node = Node('append')
+ node.comment = comment
+ chain = tokens.pop(0)
+ chains[chain].append(node)
+ while tokens:
+ token = tokens.pop(0)
+ option = Node('option')
+ option.set_property('negative', token == '!')
+ if token == '!':
+ token = tokens.pop(0)
+ option.set_property('name', token.strip('-'))
+ while tokens and not tokens[0].startswith('-') and tokens[0] != '!':
+ option.append(Node('argument', PropertyNode('value', tokens.pop(0))))
+ node.append(option)
+
+ return root
+
+ def stringify(self, tree):
+ data = ''
+ for table in tree.children:
+ data += '*%s\n' % table.name
+ for chain in table.children:
+ data += ':%s %s [0:0]\n' % (chain.name, chain.get('default').value)
+ for chain in table.children:
+ for item in chain.children:
+ if item.name == 'append':
+ data += '-A %s %s%s\n' % (
+ chain.name,
+ ' '.join(
+ ('! ' if o.get('negative').value else '') +
+ ('--' if len(o.get('name').value) > 1 else '-') + o.get('name').value + ' ' +
+ ' '.join(a.get('value').value for a in o.children if a.name == 'argument')
+ for o in item.children
+ if o.name == 'option'
+ ),
+ ' # %s' % item.comment if item.comment else ''
+ )
+ data += 'COMMIT\n'
+ return data
diff --git a/reconfigure/parsers/jsonparser.py b/reconfigure/parsers/jsonparser.py
new file mode 100644
index 0000000..c1eaee6
--- /dev/null
+++ b/reconfigure/parsers/jsonparser.py
@@ -0,0 +1,35 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+import json
+
+
+class JsonParser (BaseParser):
+ """
+ A parser for JSON files (using ``json`` module)
+ """
+
+ def parse(self, content):
+ node = RootNode()
+ self.load_node_rec(node, json.loads(content))
+ return node
+
+ def load_node_rec(self, node, json):
+ for k, v in json.iteritems():
+ if isinstance(v, dict):
+ child = Node(k)
+ node.children.append(child)
+ self.load_node_rec(child, v)
+ else:
+ node.children.append(PropertyNode(k, v))
+
+ def stringify(self, tree):
+ return json.dumps(self.save_node_rec(tree), indent=4)
+
+ def save_node_rec(self, node):
+ r = {}
+ for child in node.children:
+ if isinstance(child, PropertyNode):
+ r[child.name] = child.value
+ else:
+ r[child.name] = self.save_node_rec(child)
+ return r
diff --git a/reconfigure/parsers/nginx.py b/reconfigure/parsers/nginx.py
new file mode 100644
index 0000000..661cfcd
--- /dev/null
+++ b/reconfigure/parsers/nginx.py
@@ -0,0 +1,87 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+import re
+
+
+class NginxParser (BaseParser):
+ """
+ A parser for nginx configs
+ """
+
+ tokens = [
+ (r"[\w_]+?.+?;", lambda s, t: ('option', t)),
+ (r"\s", lambda s, t: 'whitespace'),
+ (r"$^", lambda s, t: 'newline'),
+ (r"\#.*?\n", lambda s, t: ('comment', t)),
+ (r"[\w_]+\s*?.*?{", lambda s, t: ('section_start', t)),
+ (r"\}", lambda s, t: 'section_end'),
+ ]
+ token_comment = '#'
+ token_section_end = '}'
+
+ def parse(self, content):
+ scanner = re.Scanner(self.tokens)
+ tokens, remainder = scanner.scan(' '.join(filter(None, content.split(' '))))
+ if remainder:
+ raise Exception('Invalid tokens: %s' % remainder)
+
+ node = RootNode()
+ node.parameter = None
+ node_stack = []
+ next_comment = None
+
+ while len(tokens) > 0:
+ token = tokens[0]
+ tokens = tokens[1:]
+ if token in ['whitespace', 'newline']:
+ continue
+ if token == 'section_end':
+ node = node_stack.pop()
+ if token[0] == 'comment':
+ if not next_comment:
+ next_comment = ''
+ else:
+ next_comment += '\n'
+ next_comment += token[1].strip('#/*').strip()
+ if token[0] == 'option':
+ if ' ' in token[1]:
+ k, v = token[1].split(None, 1)
+ else:
+ v = token[1]
+ k = ''
+ prop = PropertyNode(k.strip(), v[:-1].strip())
+ prop.comment = next_comment
+ next_comment = None
+ node.children.append(prop)
+ if token[0] == 'section_start':
+ line = token[1][:-1].strip().split(None, 1) + [None]
+ section = Node(line[0])
+ section.parameter = line[1]
+ section.comment = next_comment
+ next_comment = None
+ node_stack += [node]
+ node.children.append(section)
+ node = section
+
+ return node
+
+ def stringify(self, tree):
+ return ''.join(self.stringify_rec(node) for node in tree.children)
+
+ def stringify_rec(self, node):
+ if isinstance(node, PropertyNode):
+ if node.name:
+ s = '%s %s;\n' % (node.name, node.value)
+ else:
+ s = '%s;\n' % (node.value)
+ elif isinstance(node, IncludeNode):
+ s = 'include %s;\n' % (node.files)
+ else:
+ result = '\n%s %s {\n' % (node.name, node.parameter or '')
+ for child in node.children:
+ result += '\n'.join('\t' + x for x in self.stringify_rec(child).splitlines()) + '\n'
+ result += self.token_section_end + '\n'
+ s = result
+ if node.comment:
+ s = ''.join(self.token_comment + ' %s\n' % l for l in node.comment.splitlines()) + s
+ return s
diff --git a/reconfigure/parsers/nsd.py b/reconfigure/parsers/nsd.py
new file mode 100644
index 0000000..a1c0522
--- /dev/null
+++ b/reconfigure/parsers/nsd.py
@@ -0,0 +1,50 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+
+
+class NSDParser (BaseParser):
+ """
+ A parser for NSD DNS server nsd.conf file
+ """
+
+ def parse(self, content):
+ lines = content.splitlines()
+ root = RootNode()
+ last_comment = None
+ node = root
+ for line in lines:
+ line = line.strip()
+ if line:
+ if line.startswith('#'):
+ c = line.strip('#').strip()
+ if last_comment:
+ last_comment += '\n' + c
+ else:
+ last_comment = c
+ continue
+
+ key, value = line.split(':')
+ value = value.strip()
+ key = key.strip()
+ if key in ['server', 'zone', 'key']:
+ node = Node(key, comment=last_comment)
+ root.append(node)
+ else:
+ node.append(PropertyNode(key, value, comment=last_comment))
+ last_comment = None
+ return root
+
+ def stringify_comment(self, line, comment):
+ if comment:
+ return ''.join('# %s\n' % x for x in comment.splitlines()) + line
+ return line
+
+ def stringify(self, tree):
+ r = ''
+ for node in tree.children:
+ r += self.stringify_comment(node.name + ':', node.comment) + '\n'
+ for subnode in node.children:
+ l = '%s: %s' % (subnode.name, subnode.value)
+ r += self.stringify_comment(l, subnode.comment) + '\n'
+ r += '\n'
+ return r
diff --git a/reconfigure/parsers/squid.py b/reconfigure/parsers/squid.py
new file mode 100644
index 0000000..b9b4f97
--- /dev/null
+++ b/reconfigure/parsers/squid.py
@@ -0,0 +1,53 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+
+
+class SquidParser (BaseParser):
+ """
+ A parser for Squid configs
+ """
+
+ def parse(self, content):
+ lines = filter(None, [x.strip() for x in content.splitlines()])
+ root = RootNode()
+ last_comment = None
+ for line in lines:
+ line = line.strip()
+ if line.startswith('#'):
+ c = line.strip('#').strip()
+ if last_comment:
+ last_comment += '\n' + c
+ else:
+ last_comment = c
+ continue
+ if len(line) == 0:
+ continue
+ tokens = line.split()
+ node = Node('line', Node('arguments'))
+ if last_comment:
+ node.comment = last_comment
+ last_comment = None
+
+ index = 0
+ for token in tokens:
+ if token.startswith('#'):
+ node.comment = ' '.join(tokens[tokens.index(token):])[1:].strip()
+ break
+ if index == 0:
+ node.set_property('name', token)
+ else:
+ node.get('arguments').set_property(str(index), token)
+ index += 1
+ root.append(node)
+ return root
+
+ def stringify(self, tree):
+ r = ''
+ for node in tree.children:
+ if node.comment and '\n' in node.comment:
+ r += ''.join('%s %s\n' % ('#', x) for x in node.comment.splitlines())
+ r += node.get('name').value + ' ' + ' '.join(x.value for x in node.get('arguments').children)
+ if node.comment and not '\n' in node.comment:
+ r += ' # %s' % node.comment
+ r += '\n'
+ return r
diff --git a/reconfigure/parsers/ssv.py b/reconfigure/parsers/ssv.py
new file mode 100644
index 0000000..2119a4a
--- /dev/null
+++ b/reconfigure/parsers/ssv.py
@@ -0,0 +1,72 @@
+from reconfigure.nodes import *
+from reconfigure.parsers import BaseParser
+
+
+class SSVParser (BaseParser):
+ """
+ A parser for files containing space-separated value (notably, ``/etc/fstab`` and friends)
+
+ :param separator: separator character, defaults to whitespace
+ :param maxsplit: max number of tokens per line, defaults to infinity
+ :param comment: character denoting comments
+ :param continuation: line continuation character, None to disable
+ """
+
+ def __init__(self, separator=None, maxsplit=-1, comment='#', continuation=None, *args, **kwargs):
+ self.separator = separator
+ self.maxsplit = maxsplit
+ self.comment = comment
+ self.continuation = continuation
+ BaseParser.__init__(self, *args, **kwargs)
+
+ def parse(self, content):
+ rawlines = content.splitlines()
+ lines = []
+ while rawlines:
+ l = rawlines.pop(0).strip()
+ while self.continuation and rawlines and l.endswith(self.continuation):
+ l = l[:-len(self.continuation)]
+ l += rawlines.pop(0)
+ lines.append(l)
+ root = RootNode()
+ last_comment = None
+ for line in lines:
+ line = line.strip()
+ if line:
+ if line.startswith(self.comment):
+ c = line.strip(self.comment).strip()
+ if last_comment:
+ last_comment += '\n' + c
+ else:
+ last_comment = c
+ continue
+ if len(line) == 0:
+ continue
+ tokens = line.split(self.separator, self.maxsplit)
+ node = Node('line')
+ if last_comment:
+ node.comment = last_comment
+ last_comment = None
+ for token in tokens:
+ if token.startswith(self.comment):
+ node.comment = ' '.join(tokens[tokens.index(token):])[1:].strip()
+ break
+ node.append(Node(
+ name='token',
+ children=[
+ PropertyNode(name='value', value=token)
+ ]
+ ))
+ root.append(node)
+ return root
+
+ def stringify(self, tree):
+ r = ''
+ for node in tree.children:
+ if node.comment and '\n' in node.comment:
+ r += ''.join('%s %s\n' % (self.comment, x) for x in node.comment.splitlines())
+ r += (self.separator or '\t').join(x.get('value').value for x in node.children)
+ if node.comment and not '\n' in node.comment:
+ r += ' # %s' % node.comment
+ r += '\n'
+ return r
diff --git a/reconfigure/tests/__init__.py b/reconfigure/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reconfigure/tests/__init__.py
diff --git a/reconfigure/tests/configs/__init__.py b/reconfigure/tests/configs/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reconfigure/tests/configs/__init__.py
diff --git a/reconfigure/tests/configs/ajenti_tests.py b/reconfigure/tests/configs/ajenti_tests.py
new file mode 100644
index 0000000..56a1dc1
--- /dev/null
+++ b/reconfigure/tests/configs/ajenti_tests.py
@@ -0,0 +1,52 @@
+import json
+
+from reconfigure.configs import AjentiConfig
+from base_test import BaseConfigTest
+
+
+class AjentiConfigTest (BaseConfigTest):
+ sources = {
+ None: """{
+ "authentication": false,
+ "bind": {
+ "host": "0.0.0.0",
+ "port": 8000
+ },
+ "enable_feedback": true,
+ "installation_id": null,
+ "users": {
+ "test": {
+ "configs": { "a": "{}" },
+ "password": "sha512",
+ "permissions": [
+ "section:Dash"
+ ]
+ }
+ },
+ "ssl": {
+ "enable": false,
+ "certificate_path": ""
+ }
+}
+"""
+ }
+ result = {
+ 'authentication': False,
+ 'enable_feedback': True,
+ 'installation_id': None,
+ 'http_binding': {'host': '0.0.0.0', 'port': 8000},
+ 'ssl': {'certificate_path': '', 'enable': False},
+ 'users': {'test': {
+ 'configs': {'a': {'data': {}, 'name': 'a'}},
+ 'name': 'test',
+ 'password': 'sha512',
+ 'permissions': ['section:Dash']
+ }}
+ }
+
+ config = AjentiConfig
+
+ stringify_filter = staticmethod(json.loads)
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/base_test.py b/reconfigure/tests/configs/base_test.py
new file mode 100644
index 0000000..1824819
--- /dev/null
+++ b/reconfigure/tests/configs/base_test.py
@@ -0,0 +1,37 @@
+import unittest
+import json
+
+
+class BaseConfigTest (unittest.TestCase):
+ sources = ""
+ result = None
+ config = None
+ config_kwargs = {}
+ stringify_filter = staticmethod(lambda x: x.split())
+
+ def test_config(self):
+ if not self.config:
+ return
+
+ self.maxDiff = None
+
+ config = self.config(content=self.sources[None], **self.config_kwargs)
+ if config.includer:
+ config.includer.content_map = self.sources
+ config.load()
+ #print 'RESULT', config.tree.to_dict()
+ #print 'SOURCE', self.__class__.result
+ #self.assertTrue(self.__class__.result== config.tree.to_dict())
+ a, b = self.__class__.result, config.tree.to_dict()
+ if a != b:
+ print 'SOURCE: %s\nGENERATED: %s\n' % (json.dumps(a, indent=4), json.dumps(b, indent=4))
+ self.assertEquals(a, b)
+
+ result = config.save()
+ s_filter = self.__class__.stringify_filter
+ #print s_filter(result[None])
+ for k, v in result.iteritems():
+ self.assertEquals(
+ s_filter(self.__class__.sources[k]),
+ s_filter(v)
+ )
diff --git a/reconfigure/tests/configs/bind9_tests.py b/reconfigure/tests/configs/bind9_tests.py
new file mode 100644
index 0000000..ee0c05a
--- /dev/null
+++ b/reconfigure/tests/configs/bind9_tests.py
@@ -0,0 +1,28 @@
+from reconfigure.configs import BIND9Config
+from base_test import BaseConfigTest
+
+
+class BIND9ConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+zone "asd" {
+ type master;
+ file "/file";
+};
+
+"""
+ }
+ result = {
+ "zones": [
+ {
+ "type": "master",
+ "name": "asd",
+ "file": "/file"
+ }
+ ]
+ }
+
+ config = BIND9Config
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/crontab_tests.py b/reconfigure/tests/configs/crontab_tests.py
new file mode 100644
index 0000000..f1b28fe
--- /dev/null
+++ b/reconfigure/tests/configs/crontab_tests.py
@@ -0,0 +1,53 @@
+from reconfigure.configs import CrontabConfig
+from base_test import BaseConfigTest
+
+
+class CrontabConfigTest (BaseConfigTest):
+ sources = {
+ None: """#comment line
+* * * * * date
+@reboot ls -al
+1 * 0 1 2 date -s
+NAME = TEST"""
+ }
+ result = {
+ 'normal_tasks': [
+ {
+ 'minute': '*',
+ 'hour': '*',
+ 'day_of_month': '*',
+ 'month': '*',
+ 'day_of_week': '*',
+ 'command': 'date',
+ 'comment': 'comment line'
+ },
+ {
+ 'minute': '1',
+ 'hour': '*',
+ 'day_of_month': '0',
+ 'month': '1',
+ 'day_of_week': '2',
+ 'command': 'date -s',
+ 'comment': None,
+ },
+
+ ],
+ 'special_tasks': [
+ {
+ 'special': '@reboot',
+ 'command': 'ls -al',
+ 'comment': None,
+ }
+ ],
+ 'env_settings': [
+ {
+ 'name': 'NAME',
+ 'value': 'TEST',
+ 'comment': None
+ }
+ ]
+ }
+ config = CrontabConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/ctdb_tests.py b/reconfigure/tests/configs/ctdb_tests.py
new file mode 100644
index 0000000..7437ef7
--- /dev/null
+++ b/reconfigure/tests/configs/ctdb_tests.py
@@ -0,0 +1,73 @@
+from reconfigure.configs import CTDBConfig, CTDBNodesConfig, CTDBPublicAddressesConfig
+from base_test import BaseConfigTest
+
+
+class CTDBNodesConfigTest (BaseConfigTest):
+ sources = {
+ None: """10.10.1.1
+10.10.1.2
+"""
+ }
+ result = {
+ 'nodes': [
+ {
+ 'address': '10.10.1.1',
+ },
+ {
+ 'address': '10.10.1.2',
+ },
+ ]
+ }
+ config = CTDBNodesConfig
+
+
+class CTDBPublicAddressesConfigTest (BaseConfigTest):
+ sources = {
+ None: """10.10.1.1 eth0
+10.10.1.2 eth1
+"""
+ }
+ result = {
+ 'addresses': [
+ {
+ 'address': '10.10.1.1',
+ 'interface': 'eth0',
+ },
+ {
+ 'address': '10.10.1.2',
+ 'interface': 'eth1',
+ },
+ ]
+ }
+ config = CTDBPublicAddressesConfig
+
+
+class CTDBConfigTest (BaseConfigTest):
+ sources = {
+ None: """CTDB_RECOVERY_LOCK="/dadoscluster/ctdb/storage"
+CTDB_PUBLIC_INTERFACE=eth0
+CTDB_PUBLIC_ADDRESSES=/etc/ctdb/public_addresses
+CTDB_MANAGES_SAMBA=yes
+CTDB_NODES=/etc/ctdb/nodes
+CTDB_LOGFILE=/var/log/log.ctdb
+CTDB_DEBUGLEVEL=2
+CTDB_PUBLIC_NETWORK="10.0.0.0/24"
+CTDB_PUBLIC_GATEWAY="10.0.0.9"
+"""
+ }
+ result = {
+ "recovery_lock_file": "\"/dadoscluster/ctdb/storage\"",
+ "public_interface": "eth0",
+ "public_addresses_file": "/etc/ctdb/public_addresses",
+ "nodes_file": "/etc/ctdb/nodes",
+ "debug_level": "2",
+ "public_gateway": "\"10.0.0.9\"",
+ "public_network": "\"10.0.0.0/24\"",
+ "log_file": "/var/log/log.ctdb",
+ "manages_samba": True
+ }
+
+ config = CTDBConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/dhcpd_tests.py b/reconfigure/tests/configs/dhcpd_tests.py
new file mode 100644
index 0000000..73fbc74
--- /dev/null
+++ b/reconfigure/tests/configs/dhcpd_tests.py
@@ -0,0 +1,50 @@
+from reconfigure.configs import DHCPDConfig
+from base_test import BaseConfigTest
+
+
+class DHCPDConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+default-lease-time 600;
+max-lease-time 7200;
+
+ subnet 10.17.224.0 netmask 255.255.255.0 {
+ option routers rtr-224.example.org;
+ range 10.0.29.10 10.0.29.230;
+ }
+shared-network 224-29 {
+ subnet 10.17.224.0 netmask 255.255.255.0 {
+ option routers rtr-224.example.org;
+ }
+ pool {
+ deny members of "foo";
+ range 10.0.29.10 10.0.29.230;
+ }
+}
+
+"""
+ }
+ result = {
+ "subnets": [
+ {
+ "ranges": [
+ {
+ "range": "10.0.29.10 10.0.29.230"
+ }
+ ],
+ "subnets": [],
+ "name": "10.17.224.0 netmask 255.255.255.0",
+ "options": [
+ {
+ "value": "routers rtr-224.example.org"
+ }
+ ]
+ }
+ ],
+ "options": []
+ }
+
+ config = DHCPDConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/exports_tests.py b/reconfigure/tests/configs/exports_tests.py
new file mode 100644
index 0000000..be85736
--- /dev/null
+++ b/reconfigure/tests/configs/exports_tests.py
@@ -0,0 +1,45 @@
+from reconfigure.configs import ExportsConfig
+from base_test import BaseConfigTest
+
+
+class ExportsConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+/another/exported/directory 192.168.0.3(rw,sync) \
+192.168.0.4(ro) # test
+/one 192.168.0.1 # comment
+"""
+ }
+ result = {
+ "exports": [
+ {
+ "comment": "test",
+ "name": '/another/exported/directory',
+ "clients": [
+ {
+ "name": "192.168.0.3",
+ "options": "rw,sync"
+ },
+ {
+ "name": "192.168.0.4",
+ "options": "ro"
+ }
+ ]
+ },
+ {
+ "comment": "comment",
+ "name": '/one',
+ "clients": [
+ {
+ "name": "192.168.0.1",
+ "options": ""
+ }
+ ]
+ }
+ ]
+ }
+
+ config = ExportsConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/fstab_tests.py b/reconfigure/tests/configs/fstab_tests.py
new file mode 100644
index 0000000..9ffe19b
--- /dev/null
+++ b/reconfigure/tests/configs/fstab_tests.py
@@ -0,0 +1,34 @@
+from reconfigure.configs import FSTabConfig
+from base_test import BaseConfigTest
+
+
+class FSTabConfigTest (BaseConfigTest):
+ sources = {
+ None: """fs1\tmp1\text\trw\t1\t2
+fs2\tmp2\tauto\tnone\t0\t0
+"""
+ }
+ result = {
+ 'filesystems': [
+ {
+ 'device': 'fs1',
+ 'mountpoint': 'mp1',
+ 'type': 'ext',
+ 'options': 'rw',
+ 'freq': '1',
+ 'passno': '2'
+ },
+ {
+ 'device': 'fs2',
+ 'mountpoint': 'mp2',
+ 'type': 'auto',
+ 'options': 'none',
+ 'freq': '0',
+ 'passno': '0'
+ },
+ ]
+ }
+ config = FSTabConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/group_tests.py b/reconfigure/tests/configs/group_tests.py
new file mode 100644
index 0000000..b53dc5c
--- /dev/null
+++ b/reconfigure/tests/configs/group_tests.py
@@ -0,0 +1,30 @@
+from reconfigure.configs import GroupConfig
+from base_test import BaseConfigTest
+
+
+class GroupConfigTest (BaseConfigTest):
+ sources = {
+ None: """sys:x:3:
+adm:x:4:eugeny
+"""
+ }
+ result = {
+ 'groups': [
+ {
+ 'name': 'sys',
+ 'password': 'x',
+ 'gid': '3',
+ 'users': '',
+ },
+ {
+ 'name': 'adm',
+ 'password': 'x',
+ 'gid': '4',
+ 'users': 'eugeny',
+ },
+ ]
+ }
+ config = GroupConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/hosts_tests.py b/reconfigure/tests/configs/hosts_tests.py
new file mode 100644
index 0000000..e202929
--- /dev/null
+++ b/reconfigure/tests/configs/hosts_tests.py
@@ -0,0 +1,40 @@
+from reconfigure.configs import HostsConfig
+from base_test import BaseConfigTest
+
+
+class FSTabConfigTest (BaseConfigTest):
+ sources = {
+ None: """a1 h1 a2 a3 a4
+a5 h2
+a6 h3 a7
+"""
+ }
+ result = {
+ 'hosts': [
+ {
+ 'address': 'a1',
+ 'name': 'h1',
+ 'aliases': [
+ {'name': 'a2'},
+ {'name': 'a3'},
+ {'name': 'a4'},
+ ]
+ },
+ {
+ 'address': 'a5',
+ 'aliases': [],
+ 'name': 'h2',
+ },
+ {
+ 'address': 'a6',
+ 'name': 'h3',
+ 'aliases': [
+ {'name': 'a7'},
+ ]
+ },
+ ]
+ }
+ config = HostsConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/iptables_tests.py b/reconfigure/tests/configs/iptables_tests.py
new file mode 100644
index 0000000..335e081
--- /dev/null
+++ b/reconfigure/tests/configs/iptables_tests.py
@@ -0,0 +1,100 @@
+from reconfigure.configs import IPTablesConfig
+from base_test import BaseConfigTest
+
+
+class IPTablesConfigTest (BaseConfigTest):
+ sources = {
+ None: '''*filter
+:INPUT ACCEPT [0:0]
+:FORWARD DROP [0:0]
+:OUTPUT ACCEPT [0:0]
+-A INPUT ! -s 202.54.1.2/32 -j DROP
+-A INPUT -m state --state NEW,ESTABLISHED -j ACCEPT # test
+COMMIT
+'''
+ }
+ result = {
+ 'tables': [
+ {
+ 'chains': [
+ {
+ 'default': 'ACCEPT',
+ 'rules': [
+ {
+ 'options': [
+ {
+ 'arguments': [
+ {
+ 'value': '202.54.1.2/32'
+ }
+ ],
+ 'negative': True,
+ 'name': 's'
+ },
+ {
+ 'arguments': [
+ {
+ 'value': 'DROP'
+ }
+ ],
+ 'negative': False,
+ 'name': 'j'
+ }
+ ],
+ 'comment': None,
+ },
+ {
+ 'options': [
+ {
+ 'arguments': [
+ {
+ 'value': 'state'
+ }
+ ],
+ 'negative': False,
+ 'name': 'm'
+ },
+ {
+ 'arguments': [
+ {
+ 'value': 'NEW,ESTABLISHED'
+ }
+ ],
+ 'negative': False,
+ 'name': 'state'
+ },
+ {
+ 'arguments': [
+ {
+ 'value': 'ACCEPT'
+ }
+ ],
+ 'negative': False,
+ 'name': 'j'
+ }
+ ],
+ 'comment': 'test',
+ }
+ ],
+ 'name': 'INPUT'
+ },
+ {
+ 'default': 'DROP',
+ 'rules': [],
+ 'name': 'FORWARD'
+ },
+ {
+ 'default': 'ACCEPT',
+ 'rules': [],
+ 'name': 'OUTPUT'
+ }
+ ],
+ 'name': 'filter'
+ }
+ ]
+ }
+
+ config = IPTablesConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/netatalk_tests.py b/reconfigure/tests/configs/netatalk_tests.py
new file mode 100644
index 0000000..ab9f693
--- /dev/null
+++ b/reconfigure/tests/configs/netatalk_tests.py
@@ -0,0 +1,41 @@
+from reconfigure.configs import NetatalkConfig
+from base_test import BaseConfigTest
+
+
+class NetatalkConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+[Global]
+afp port=123
+
+[test]
+path=/home ;comment
+valid users=root
+ea=sys
+"""
+ }
+
+ result = {
+ "global": {
+ "zeroconf": True,
+ "cnid_listen": "localhost:4700",
+ "afp_port": "123",
+ },
+ "shares": [
+ {
+ "comment": "comment",
+ "appledouble": "ea",
+ "name": "test",
+ "ea": "sys",
+ "valid_users": "root",
+ "cnid_scheme": "dbd",
+ "path": "/home",
+ "password": '',
+ }
+ ]
+ }
+
+ config = NetatalkConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/nsd_tests.py b/reconfigure/tests/configs/nsd_tests.py
new file mode 100644
index 0000000..d91b0bd
--- /dev/null
+++ b/reconfigure/tests/configs/nsd_tests.py
@@ -0,0 +1,26 @@
+from reconfigure.configs import NSDConfig
+from base_test import BaseConfigTest
+
+
+class NSDConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+zone:
+ name: "example.net"
+ zonefile: "example.net.signed.zone"
+ notify-retry: 5
+"""
+ }
+ result = {
+ "zones": [
+ {
+ "name": "example.net",
+ "file": "example.net.signed.zone"
+ }
+ ]
+ }
+
+ config = NSDConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/passwd_tests.py b/reconfigure/tests/configs/passwd_tests.py
new file mode 100644
index 0000000..8f9a5de
--- /dev/null
+++ b/reconfigure/tests/configs/passwd_tests.py
@@ -0,0 +1,36 @@
+from reconfigure.configs import PasswdConfig
+from base_test import BaseConfigTest
+
+
+class PasswdConfigTest (BaseConfigTest):
+ sources = {
+ None: """backup:x:34:34:backup:/var/backups:/bin/sh
+list:x:38:38:Mailing List Manager:/var/list:/bin/sh
+"""
+ }
+ result = {
+ 'users': [
+ {
+ 'name': 'backup',
+ 'password': 'x',
+ 'uid': '34',
+ 'gid': '34',
+ 'comment': 'backup',
+ 'home': '/var/backups',
+ 'shell': '/bin/sh'
+ },
+ {
+ 'name': 'list',
+ 'password': 'x',
+ 'uid': '38',
+ 'gid': '38',
+ 'comment': 'Mailing List Manager',
+ 'home': '/var/list',
+ 'shell': '/bin/sh'
+ },
+ ]
+ }
+ config = PasswdConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/resolv_tests.py b/reconfigure/tests/configs/resolv_tests.py
new file mode 100644
index 0000000..d7bedc8
--- /dev/null
+++ b/reconfigure/tests/configs/resolv_tests.py
@@ -0,0 +1,31 @@
+from reconfigure.configs import ResolvConfig
+from base_test import BaseConfigTest
+
+
+class ResolvConfigTest (BaseConfigTest):
+ sources = {
+ None: """nameserver 1
+domain 2
+search 3 5
+"""
+ }
+ result = {
+ 'items': [
+ {
+ 'name': 'nameserver',
+ 'value': '1',
+ },
+ {
+ 'name': 'domain',
+ 'value': '2',
+ },
+ {
+ 'name': 'search',
+ 'value': '3 5',
+ },
+ ]
+ }
+ config = ResolvConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/samba_tests.py b/reconfigure/tests/configs/samba_tests.py
new file mode 100644
index 0000000..c6ce44c
--- /dev/null
+++ b/reconfigure/tests/configs/samba_tests.py
@@ -0,0 +1,70 @@
+from reconfigure.configs import SambaConfig
+from base_test import BaseConfigTest
+
+
+class SambaConfigTest (BaseConfigTest):
+ sources = {
+ None: """
+[global]
+workgroup=WORKGROUP
+server string=%h server (Samba, Ubuntu)
+interfaces=127.0.0.0/8 eth0
+bind interfaces only=yes
+log file=/var/log/samba/log.%m
+security=user
+
+[homes]
+comment=Home Directories
+browseable=no
+
+[profiles]
+comment=Users profiles
+path=/home/samba/profiles
+guest ok=no
+browseable=no
+create mask=0600
+directory mask=0700
+"""
+ }
+
+ result = {
+ "global": {
+ "server_string": "%h server (Samba, Ubuntu)",
+ "workgroup": "WORKGROUP",
+ "interfaces": "127.0.0.0/8 eth0",
+ "bind_interfaces_only": True,
+ "security": "user",
+ "log_file": "/var/log/samba/log.%m"
+ },
+ "shares": [
+ {
+ "name": "homes",
+ "comment": "Home Directories",
+ "browseable": False,
+ "create_mask": "0744",
+ "directory_mask": "0755",
+ 'follow_symlinks': True,
+ "read_only": True,
+ "guest_ok": False,
+ "path": "",
+ 'wide_links': False,
+ },
+ {
+ "name": "profiles",
+ "comment": "Users profiles",
+ "browseable": False,
+ "create_mask": "0600",
+ "directory_mask": "0700",
+ 'follow_symlinks': True,
+ "read_only": True,
+ "guest_ok": False,
+ "path": "/home/samba/profiles",
+ 'wide_links': False
+ }
+ ]
+ }
+
+ config = SambaConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/squid_tests.py b/reconfigure/tests/configs/squid_tests.py
new file mode 100644
index 0000000..011d4bf
--- /dev/null
+++ b/reconfigure/tests/configs/squid_tests.py
@@ -0,0 +1,63 @@
+from reconfigure.configs import SquidConfig
+from base_test import BaseConfigTest
+
+
+class SquidConfigTest (BaseConfigTest):
+ sources = {
+ None: """acl manager proto cache_object
+acl SSL_ports port 443
+http_access deny CONNECT !SSL_ports
+http_port 3128
+"""
+ }
+ result = {
+ "http_access": [
+ {
+ "mode": "deny",
+ "options": [
+ {
+ "value": "CONNECT"
+ },
+ {
+ "value": "!SSL_ports"
+ }
+ ]
+ }
+ ],
+ "http_port": [
+ {
+ "options": [],
+ "port": "3128"
+ }
+ ],
+ "https_port": [],
+ "acl": [
+ {
+ "name": "manager",
+ "options": [
+ {
+ "value": "proto"
+ },
+ {
+ "value": "cache_object"
+ }
+ ]
+ },
+ {
+ "name": "SSL_ports",
+ "options": [
+ {
+ "value": "port"
+ },
+ {
+ "value": "443"
+ }
+ ]
+ }
+ ]
+ }
+
+ config = SquidConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/configs/supervisor_tests.py b/reconfigure/tests/configs/supervisor_tests.py
new file mode 100644
index 0000000..8656128
--- /dev/null
+++ b/reconfigure/tests/configs/supervisor_tests.py
@@ -0,0 +1,36 @@
+from reconfigure.configs import SupervisorConfig
+from base_test import BaseConfigTest
+
+
+class SupervisorConfigTest (BaseConfigTest):
+ sources = {
+ None: """[unix_http_server]
+file=/var/run//supervisor.sock ;comment
+chmod=0700
+[include]
+files=test""",
+ 'test': """[program:test1]
+command=cat
+ """
+ }
+ result = {
+ "programs": [
+ {
+ "autorestart": None,
+ "name": "test1",
+ "startsecs": None,
+ "umask": None,
+ "environment": None,
+ "command": "cat",
+ "user": None,
+ "startretries": None,
+ "directory": None,
+ "autostart": None
+ }
+ ]
+ }
+
+ config = SupervisorConfig
+
+
+del BaseConfigTest
diff --git a/reconfigure/tests/includers/__init__.py b/reconfigure/tests/includers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reconfigure/tests/includers/__init__.py
diff --git a/reconfigure/tests/includers/nginx_tests.py b/reconfigure/tests/includers/nginx_tests.py
new file mode 100644
index 0000000..ad980a1
--- /dev/null
+++ b/reconfigure/tests/includers/nginx_tests.py
@@ -0,0 +1,29 @@
+#coding: utf8
+import unittest
+from reconfigure.parsers import NginxParser
+from reconfigure.includers import NginxIncluder
+
+
+class IncludersTest (unittest.TestCase):
+ def test_compose_decompose(self):
+ content = """
+ sec1 {
+ p1 1;
+ include test;
+ }
+ """
+ content2 = """
+ sec2 {
+ p2 2;
+ }
+ """
+
+ parser = NginxParser()
+ includer = NginxIncluder(parser=parser, content_map={'test': content2})
+ tree = parser.parse(content)
+ tree = includer.compose(None, tree)
+ self.assertTrue(len(tree.children[0].children) == 3)
+
+ treemap = includer.decompose(tree)
+ self.assertTrue(len(treemap.keys()) == 2)
+ self.assertTrue(treemap['test'].children[0].name == 'sec2')
diff --git a/reconfigure/tests/parsers/__init__.py b/reconfigure/tests/parsers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/reconfigure/tests/parsers/__init__.py
diff --git a/reconfigure/tests/parsers/base_test.py b/reconfigure/tests/parsers/base_test.py
new file mode 100644
index 0000000..74c94b5
--- /dev/null
+++ b/reconfigure/tests/parsers/base_test.py
@@ -0,0 +1,30 @@
+import unittest
+
+
+class BaseParserTest (unittest.TestCase):
+ source = ""
+ parsed = None
+ parser = None
+
+ @property
+ def stringified(self):
+ return self.source
+
+ def test_parse(self):
+ if not self.__class__.parser:
+ return
+
+ nodetree = self.parser.parse(self.__class__.source)
+ if self.__class__.parsed != nodetree:
+ print 'TARGET: %s\n\nPARSED: %s' % (self.__class__.parsed, nodetree)
+ self.assertEquals(self.__class__.parsed, nodetree)
+
+ def test_stringify(self):
+ if not self.__class__.parser:
+ return
+
+ unparsed = self.parser.stringify(self.__class__.parsed)
+ a, b = self.stringified, unparsed
+ if a.split() != b.split():
+ print 'SOURCE: %s\n\nGENERATED: %s' % (a, b)
+ self.assertEquals(a.split(), b.split())
diff --git a/reconfigure/tests/parsers/bind9_tests.py b/reconfigure/tests/parsers/bind9_tests.py
new file mode 100644
index 0000000..0f2527e
--- /dev/null
+++ b/reconfigure/tests/parsers/bind9_tests.py
@@ -0,0 +1,55 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import BIND9Parser
+from reconfigure.nodes import *
+
+
+class BIND9ParserTest (BaseParserTest):
+ parser = BIND9Parser()
+ source = """p1 asd;
+
+sec {
+ s1p1 asd;
+ /*s1p2 wqe;*/
+
+ sec2 test {
+ ::1;
+ s2p1 qwe;
+ };
+};
+"""
+
+ @property
+ def stringified(self):
+ return """
+ p1 asd;
+
+sec {
+ s1p1 asd;
+
+ # s1p2 wqe;
+ sec2 test {
+ ::1;
+ s2p1 qwe;
+ };
+};
+"""
+
+ parsed = RootNode(
+ None,
+ PropertyNode('p1', 'asd'),
+ Node(
+ 'sec',
+ PropertyNode('s1p1', 'asd'),
+ Node(
+ 'sec2',
+ PropertyNode('', '::1'),
+ PropertyNode('s2p1', 'qwe'),
+ parameter='test',
+ comment='s1p2 wqe;',
+ ),
+ parameter=None,
+ )
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/crontab_tests.py b/reconfigure/tests/parsers/crontab_tests.py
new file mode 100644
index 0000000..481a2f6
--- /dev/null
+++ b/reconfigure/tests/parsers/crontab_tests.py
@@ -0,0 +1,57 @@
+from reconfigure.parsers import CrontabParser
+from reconfigure.nodes import RootNode, Node, PropertyNode
+from reconfigure.tests.parsers.base_test import BaseParserTest
+
+
+class CrontabParserTest (BaseParserTest):
+ parser = CrontabParser()
+
+ source = '\n'.join(['#comment line',
+ '* * * * * date',
+ '@reboot ls -al',
+ '1 * 0 1 2 date -s',
+ 'NAME = TEST',
+ ])
+ parsed = RootNode(None,
+ children=[
+ Node('normal_task',
+ comment='comment line',
+ children=[
+ PropertyNode('minute', '*'),
+ PropertyNode('hour', '*'),
+ PropertyNode('day_of_month', '*'),
+ PropertyNode('month', '*'),
+ PropertyNode('day_of_week', '*'),
+ PropertyNode('command', 'date'),
+ ]
+ ),
+ Node('special_task',
+ children=[
+ PropertyNode('special', '@reboot'),
+ PropertyNode('command', 'ls -al'),
+ ]
+ ),
+ Node('normal_task',
+ children=[
+ PropertyNode('minute', '1'),
+ PropertyNode('hour', '*'),
+ PropertyNode('day_of_month', '0'),
+ PropertyNode('month', '1'),
+ PropertyNode('day_of_week', '2'),
+ PropertyNode('command', 'date -s'),
+ ]
+ ),
+ Node('env_setting',
+ children=[
+ PropertyNode('name', 'NAME'),
+ PropertyNode('value', 'TEST'),
+ ]
+ ),
+ ]
+ )
+# bad_source = '\n'.join(['* * * * dd', #Wrong line
+# ' = FAIL', #wrong line
+# ])
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/exports_tests.py b/reconfigure/tests/parsers/exports_tests.py
new file mode 100644
index 0000000..e83552a
--- /dev/null
+++ b/reconfigure/tests/parsers/exports_tests.py
@@ -0,0 +1,50 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import ExportsParser
+from reconfigure.nodes import *
+
+
+class ExportsParserTest (BaseParserTest):
+ parser = ExportsParser()
+ source = """
+/another/exported/directory 192.168.0.3(rw,sync) \
+192.168.0.4(ro)
+# comment
+/one 192.168.0.1
+"""
+ parsed = RootNode(
+ None,
+ Node(
+ '/another/exported/directory',
+ Node(
+ 'clients',
+ Node(
+ '192.168.0.3',
+ PropertyNode('options', 'rw,sync')
+ ),
+ Node(
+ '192.168.0.4',
+ PropertyNode('options', 'ro')
+ ),
+ ),
+ ),
+ Node(
+ '/one',
+ Node(
+ 'clients',
+ Node(
+ '192.168.0.1',
+ PropertyNode('options', '')
+ ),
+ ),
+ comment='comment'
+ )
+ )
+
+ @property
+ def stringified(self):
+ return """/another/exported/directory\t192.168.0.3(rw,sync)\t192.168.0.4(ro)
+/one\t192.168.0.1\t# comment
+"""
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/ini_tests.py b/reconfigure/tests/parsers/ini_tests.py
new file mode 100644
index 0000000..cdb3c02
--- /dev/null
+++ b/reconfigure/tests/parsers/ini_tests.py
@@ -0,0 +1,26 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import IniFileParser
+from reconfigure.nodes import *
+
+
+class IniParserTest (BaseParserTest):
+ parser = IniFileParser(sectionless=True)
+ source = """a=b
+
+[section1] ;section comment
+s1p1=asd ;comment 2
+s1p2=123
+"""
+ parsed = RootNode(None,
+ Node(None,
+ PropertyNode('a', 'b'),
+ ),
+ Node('section1',
+ PropertyNode('s1p1', 'asd', comment='comment 2'),
+ PropertyNode('s1p2', '123'),
+ comment='section comment'
+ ),
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/iptables_tests.py b/reconfigure/tests/parsers/iptables_tests.py
new file mode 100644
index 0000000..21e09fa
--- /dev/null
+++ b/reconfigure/tests/parsers/iptables_tests.py
@@ -0,0 +1,61 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import IPTablesParser
+from reconfigure.nodes import *
+
+
+class IPTablesParserTest (BaseParserTest):
+ parser = IPTablesParser()
+ source = """*filter
+:INPUT ACCEPT [0:0]
+:FORWARD DROP [0:0]
+:OUTPUT ACCEPT [0:0]
+-A INPUT ! -s 202.54.1.2/32 -j DROP # test
+-A INPUT -m state --state NEW,ESTABLISHED -j ACCEPT
+COMMIT
+"""
+ parsed = RootNode(None,
+ Node('filter',
+ Node('INPUT',
+ PropertyNode('default', 'ACCEPT'),
+ Node('append',
+ Node('option',
+ Node('argument', PropertyNode('value', '202.54.1.2/32')),
+ PropertyNode('negative', True),
+ PropertyNode('name', 's')
+ ),
+ Node('option',
+ Node('argument', PropertyNode('value', 'DROP')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'j')
+ ),
+ comment='test'
+ ),
+ Node('append',
+ Node('option',
+ Node('argument', PropertyNode('value', 'state')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'm')
+ ),
+ Node('option',
+ Node('argument', PropertyNode('value', 'NEW,ESTABLISHED')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'state')
+ ),
+ Node('option',
+ Node('argument', PropertyNode('value', 'ACCEPT')),
+ PropertyNode('negative', False),
+ PropertyNode('name', 'j')
+ ),
+ ),
+ ),
+ Node('FORWARD',
+ PropertyNode('default', 'DROP'),
+ ),
+ Node('OUTPUT',
+ PropertyNode('default', 'ACCEPT'),
+ ),
+ )
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/jsonparser_tests.py b/reconfigure/tests/parsers/jsonparser_tests.py
new file mode 100644
index 0000000..1c1312e
--- /dev/null
+++ b/reconfigure/tests/parsers/jsonparser_tests.py
@@ -0,0 +1,24 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import JsonParser
+from reconfigure.nodes import *
+
+
+class JsonParserTest (BaseParserTest):
+ parser = JsonParser()
+ source = """{
+ "p2": 123,
+ "s1": {
+ "s1p1": "qwerty"
+ }
+}
+"""
+
+ parsed = RootNode(None,
+ PropertyNode('p2', 123),
+ Node('s1',
+ PropertyNode('s1p1', 'qwerty'),
+ ),
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/nginx_tests.py b/reconfigure/tests/parsers/nginx_tests.py
new file mode 100644
index 0000000..83fe8b2
--- /dev/null
+++ b/reconfigure/tests/parsers/nginx_tests.py
@@ -0,0 +1,38 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import NginxParser
+from reconfigure.nodes import *
+
+
+class NginxParserTest (BaseParserTest):
+ parser = NginxParser()
+ source = """p1 asd;
+
+sec {
+ s1p1 asd;
+ s1p2 wqe;
+
+ # test
+ sec2 test {
+ s2p1 qwe;
+ }
+}
+"""
+ parsed = RootNode(
+ None,
+ PropertyNode('p1', 'asd'),
+ Node(
+ 'sec',
+ PropertyNode('s1p1', 'asd'),
+ PropertyNode('s1p2', 'wqe'),
+ Node(
+ 'sec2',
+ PropertyNode('s2p1', 'qwe'),
+ parameter='test',
+ comment='test',
+ ),
+ parameter=None,
+ )
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/nsd_tests.py b/reconfigure/tests/parsers/nsd_tests.py
new file mode 100644
index 0000000..54bd7a4
--- /dev/null
+++ b/reconfigure/tests/parsers/nsd_tests.py
@@ -0,0 +1,29 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import NSDParser
+from reconfigure.nodes import *
+
+
+class BIND9ParserTest (BaseParserTest):
+ parser = NSDParser()
+ source = """# asd
+ server:
+ ip4-only: no
+key:
+ name: "mskey"
+"""
+
+ parsed = RootNode(
+ None,
+ Node(
+ 'server',
+ PropertyNode('ip4-only', 'no'),
+ comment='asd'
+ ),
+ Node(
+ 'key',
+ PropertyNode('name', '"mskey"'),
+ )
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/squid_tests.py b/reconfigure/tests/parsers/squid_tests.py
new file mode 100644
index 0000000..1896b5f
--- /dev/null
+++ b/reconfigure/tests/parsers/squid_tests.py
@@ -0,0 +1,29 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import SquidParser
+from reconfigure.nodes import *
+
+
+class SquidParserTest (BaseParserTest):
+ parser = SquidParser()
+ source = """# line1
+# long comment
+a\tbc
+efgh # line2
+"""
+ parsed = RootNode(None,
+ Node('line',
+ PropertyNode('name', 'a'),
+ Node('arguments',
+ PropertyNode('1', 'bc'),
+ ),
+ comment='line1\nlong comment',
+ ),
+ Node('line',
+ PropertyNode('name', 'efgh'),
+ Node('arguments'),
+ comment='line2',
+ ),
+ )
+
+
+del BaseParserTest
diff --git a/reconfigure/tests/parsers/ssv_tests.py b/reconfigure/tests/parsers/ssv_tests.py
new file mode 100644
index 0000000..4df9ff5
--- /dev/null
+++ b/reconfigure/tests/parsers/ssv_tests.py
@@ -0,0 +1,39 @@
+from reconfigure.tests.parsers.base_test import BaseParserTest
+from reconfigure.parsers import SSVParser
+from reconfigure.nodes import *
+
+
+class SSVParserTest (BaseParserTest):
+ parser = SSVParser(continuation='\\')
+ source = """# line1
+# long comment
+a\tbc\\
+\tdef
+efgh # line2
+"""
+ parsed = RootNode(
+ None,
+ Node(
+ 'line',
+ Node('token', PropertyNode('value', 'a')),
+ Node('token', PropertyNode('value', 'bc')),
+ Node('token', PropertyNode('value', 'def')),
+ comment='line1\nlong comment',
+ ),
+ Node(
+ 'line',
+ Node('token', PropertyNode('value', 'efgh')),
+ comment='line2',
+ ),
+ )
+
+ @property
+ def stringified(self):
+ return """# line1
+# long comment
+a\tbc\tdef
+efgh # line2
+"""
+
+
+del BaseParserTest
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/requirements.txt
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..0c40876
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+from setuptools import find_packages
+
+from reconfigure import __version__
+
+setup(
+ name='reconfigure',
+ version=__version__,
+ install_requires=[
+ 'chardet',
+ ],
+ description='An ORM for config files',
+ author='Eugeny Pankov',
+ author_email='e@ajenti.org',
+ url='http://ajenti.org/',
+ packages=find_packages(exclude=['*test*']),
+)