diff options
author | Andrew Shadura <andrew@shadura.me> | 2015-08-20 15:58:26 +0200 |
---|---|---|
committer | Andrew Shadura <andrew@shadura.me> | 2015-08-20 15:58:26 +0200 |
commit | ff1408420159488a106492ccd11dd234967029b6 (patch) | |
tree | 473420cee1c5229a427ec4cafead1aa6c0a26800 |
Imported Upstream version 0.1.29
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*']), +) |