diff options
| author | Christophe Vu-Brugier <[email protected]> | 2016-09-22 22:29:14 +0200 |
|---|---|---|
| committer | git-ubuntu importer <[email protected]> | 2016-10-07 04:27:07 +0000 |
| commit | 37b362126fa68ea2eebeb1bf0c141d4764c234ee (patch) | |
| tree | 19b9a8c549377eacafe20f4ddf3540fc3d7943cb | |
1.1.20-1 (patches unapplied)import/1.1.20-1ubuntu/zesty-proposedubuntu/zesty-develubuntu/zestyubuntu/bionic-develubuntu/bionicubuntu/artful-develubuntu/artfuldebian/stretch
Imported using git-ubuntu import.
Notes
Notes:
| -rw-r--r-- | .gitignore | 24 | ||||
| -rw-r--r-- | COPYING | 176 | ||||
| -rw-r--r-- | Makefile | 117 | ||||
| -rw-r--r-- | README.md | 41 | ||||
| -rw-r--r-- | configshell/__init__.py | 27 | ||||
| -rw-r--r-- | configshell/console.py | 425 | ||||
| -rw-r--r-- | configshell/log.py | 171 | ||||
| -rw-r--r-- | configshell/node.py | 1865 | ||||
| -rw-r--r-- | configshell/prefs.py | 149 | ||||
| -rw-r--r-- | configshell/shell.py | 907 | ||||
| l--------- | configshell_fb | 1 | ||||
| -rw-r--r-- | debian/changelog | 6 | ||||
| -rw-r--r-- | debian/compat | 1 | ||||
| -rw-r--r-- | debian/control | 82 | ||||
| -rw-r--r-- | debian/copyright | 38 | ||||
| -rw-r--r-- | debian/gbp.conf | 12 | ||||
| -rw-r--r-- | debian/python-configshell-fb-doc.doc-base | 9 | ||||
| -rw-r--r-- | debian/python-configshell-fb.install | 1 | ||||
| -rw-r--r-- | debian/python3-configshell-fb.install | 1 | ||||
| -rwxr-xr-x | debian/rules | 18 | ||||
| -rw-r--r-- | debian/source/format | 1 | ||||
| -rw-r--r-- | debian/watch | 3 | ||||
| -rwxr-xr-x | examples/myshell | 173 | ||||
| -rw-r--r-- | rpm/python-configshell.spec.tmpl | 44 | ||||
| -rwxr-xr-x | setup.py | 35 |
25 files changed, 4327 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93e9303 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +debian/changelog +dpkg-buildpackage.log +dpkg-buildpackage.version +*.swp +*.swo +build-stamp +build/* +debian/files +debian/python-configshell.debhelper.log +debian/python-configshell.substvars +debian/python-configshell/ +debian/configshell-doc.debhelper.log +debian/configshell-doc.substvars +debian/configshell-doc/ +debian/tmp/ +dist/* +doc/* +*.pyc +debian/python-configshell.substvars +debian/configshell-doc.debhelper.log +debian/tmp/ +*.spec +*.pyc +rtslib-* @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f098b9 --- /dev/null +++ b/Makefile @@ -0,0 +1,117 @@ +PKGNAME = configshell-fb +NAME = configshell +GIT_BRANCH = $$(git branch | grep \* | tr -d \*) +VERSION = $$(basename $$(git describe --tags | tr - . | sed 's/^v//')) + +all: + @echo "Usage:" + @echo + @echo " make deb - Builds debian packages." + @echo " make rpm - Builds rpm packages." + @echo " make release - Generates the release tarball." + @echo + @echo " make clean - Cleanup the local repository build files." + @echo " make cleanall - Also remove dist/*" + +clean: + @rm -fv ${NAME}/*.pyc ${NAME}/*.html + @rm -frv doc + @rm -frv ${NAME}.egg-info MANIFEST build + @rm -frv debian/tmp + @rm -fv build-stamp + @rm -fv dpkg-buildpackage.log dpkg-buildpackage.version + @rm -frv *.rpm + @rm -fv debian/files debian/*.log debian/*.substvars + @rm -frv debian/${PKGNAME}-doc/ debian/python2.5-${PKGNAME}/ + @rm -frv debian/python2.6-${PKGNAME}/ debian/python-${PKGNAME}/ + @rm -frv results + @rm -fv rpm/*.spec *.spec rpm/sed* sed* + @rm -frv ${PKGNAME}-* + @echo "Finished cleanup." + +cleanall: clean + @rm -frv dist + +release: build/release-stamp +build/release-stamp: + @mkdir -p build + @echo "Exporting the repository files..." + @git archive ${GIT_BRANCH} --prefix ${PKGNAME}-${VERSION}/ \ + | (cd build; tar xfp -) + @echo "Cleaning up the target tree..." + @rm -f build/${PKGNAME}-${VERSION}/Makefile + @rm -f build/${PKGNAME}-${VERSION}/.gitignore + @echo "Fixing version string..." + @sed -i "s/__version__ = .*/__version__ = '${VERSION}'/g" \ + build/${PKGNAME}-${VERSION}/${NAME}/__init__.py + @echo "Generating rpm specfile from template..." + @cd build/${PKGNAME}-${VERSION}; \ + for spectmpl in rpm/*.spec.tmpl; do \ + sed -i "s/Version:\( *\).*/Version:\1${VERSION}/g" $${spectmpl}; \ + mv $${spectmpl} $$(basename $${spectmpl} .tmpl); \ + done; \ + rm -r rpm + @echo "Generating rpm changelog..." + @( \ + version=${VERSION}; \ + author=$$(git show HEAD --format="format:%an <%ae>" -s); \ + date=$$(git show HEAD --format="format:%ad" -s \ + | awk '{print $$1,$$2,$$3,$$5}'); \ + hash=$$(git show HEAD --format="format:%H" -s); \ + echo '* '"$${date} $${author} $${version}-1"; \ + echo " - Generated from git commit $${hash}."; \ + ) >> $$(ls build/${PKGNAME}-${VERSION}/*.spec) + @echo "Generating debian changelog..." + @( \ + version=${VERSION}; \ + author=$$(git show HEAD --format="format:%an <%ae>" -s); \ + date=$$(git show HEAD --format="format:%aD" -s); \ + day=$$(git show HEAD --format='format:%ai' -s \ + | awk '{print $$1}' \ + | awk -F '-' '{print $$3}' | sed 's/^0/ /g'); \ + date=$$(echo $${date} \ + | awk '{print $$1, "'"$${day}"'", $$3, $$4, $$5, $$6}'); \ + hash=$$(git show HEAD --format="format:%H" -s); \ + echo "${PKGNAME} ($${version}) unstable; urgency=low"; \ + echo; \ + echo " * Generated from git commit $${hash}."; \ + echo; \ + echo " -- $${author} $${date}"; \ + echo; \ + ) > build/${PKGNAME}-${VERSION}/debian/changelog + @find build/${PKGNAME}-${VERSION}/ -exec \ + touch -t $$(date -d @$$(git show -s --format="format:%at") \ + +"%Y%m%d%H%M.%S") {} \; + @mkdir -p dist + @cd build; tar -c --owner=0 --group=0 --numeric-owner \ + --format=gnu -b20 --quoting-style=escape \ + -f ../dist/${PKGNAME}-${VERSION}.tar \ + $$(find ${PKGNAME}-${VERSION} -type f | sort) + @gzip -6 -n dist/${PKGNAME}-${VERSION}.tar + @echo "Generated release tarball:" + @echo " $$(ls dist/${PKGNAME}-${VERSION}.tar.gz)" + @touch build/release-stamp + +deb: release build/deb-stamp +build/deb-stamp: + @echo "Building debian packages..." + @cd build/${PKGNAME}-${VERSION}; \ + dpkg-buildpackage -rfakeroot -us -uc + @mv build/*_${VERSION}_*.deb dist/ + @echo "Generated debian packages:" + @for pkg in $$(ls dist/*_${VERSION}_*.deb); do echo " $${pkg}"; done + @touch build/deb-stamp + +rpm: release build/rpm-stamp +build/rpm-stamp: + @echo "Building rpm packages..." + @mkdir -p build/rpm + @build=$$(pwd)/build/rpm; dist=$$(pwd)/dist/; rpmbuild \ + --define "_topdir $${build}" --define "_sourcedir $${dist}" \ + --define "_rpmdir $${build}" --define "_buildir $${build}" \ + --define "_srcrpmdir $${build}" -ba build/${PKGNAME}-${VERSION}/*.spec + @mv build/rpm/*-${VERSION}*.src.rpm dist/ + @mv build/rpm/*/*-${VERSION}*.rpm dist/ + @echo "Generated rpm packages:" + @for pkg in $$(ls dist/*-${VERSION}*.rpm); do echo " $${pkg}"; done + @touch build/rpm-stamp diff --git a/README.md b/README.md new file mode 100644 index 0000000..459f5f3 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +configshell-fb +============== + +A Python library for building configuration shells +-------------------------------------------------- +configshell-fb is a Python library that provides a framework +for building simple but nice CLI-based applications. + +This runs with Python 2 and 2to3 is run by setup.py to run on Python 3. + +configshell-fb development +-------------------------- +configshell-fb is licensed under the Apache 2.0 license. Contributions are welcome. + +Since configshell-fb is used most often with targetcli-fb, the +targetcli-fb mailing should be used for configshell-fb discussion. + + * Mailing list: [targetcli-fb-devel](https://lists.fedorahosted.org/mailman/listinfo/targetcli-fb-devel) + * Source repo: [GitHub](https://github.com/agrover/configshell-fb) + * Bugs: [GitHub](https://github.com/agrover/configshell-fb/issues) or [Trac](https://fedorahosted.org/targetcli-fb/) + * Tarballs: [fedorahosted](https://fedorahosted.org/releases/t/a/targetcli-fb/) + +In-repo packaging +----------------- +Packaging scripts for RPM and DEB are included, but these are to make end-user +custom packaging easier -- distributions tend to maintain their own packaging +scripts separately. If you run into issues with packaging, start with opening +a bug on your distro's bug reporting system. + +Some people do use these scripts, so we want to keep them around. Fixes for +any breakage you encounter are welcome. + +"fb" -- "free branch" +--------------------- + +configshell-fb is a fork of the "configshell" code written by +RisingTide Systems. The "-fb" differentiates between the original and +this version. Please ensure to use either all "fb" versions of the +targetcli components -- targetcli, rtslib, and configshell, or stick +with all non-fb versions, since they are no longer strictly +compatible. diff --git a/configshell/__init__.py b/configshell/__init__.py new file mode 100644 index 0000000..8580669 --- /dev/null +++ b/configshell/__init__.py @@ -0,0 +1,27 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +if __name__ == "configshell": + from warnings import warn + warn("'configshell' package name for configshell-fb is deprecated, please" + + " instead import 'configshell_fb'", UserWarning, stacklevel=2) + +from .console import Console +from .log import Log +from .node import ConfigNode, ExecutionError +from .prefs import Prefs +from .shell import ConfigShell diff --git a/configshell/console.py b/configshell/console.py new file mode 100644 index 0000000..8ed6b50 --- /dev/null +++ b/configshell/console.py @@ -0,0 +1,425 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +from fcntl import ioctl +import re +import six +import struct +import sys +from termios import TIOCGWINSZ, TCSADRAIN, tcsetattr, tcgetattr +import textwrap +import tty + +from .prefs import Prefs + +# avoid requiring epydoc at runtime +try: + import epydoc.markup.epytext +except ImportError: + pass + +class Console(object): + ''' + Implements various utility methods providing a console UI support toolkit, + most notably an epytext-to-console text renderer using ANSI escape + sequences. It uses the Borg pattern to share state between instances. + ''' + _max_width = 132 + _escape = '\033[' + _ansi_format = _escape + '%dm%s' + _ansi_reset = _escape + '0m' + _re_ansi_seq = re.compile('(\033\[..?m)') + + _ansi_styles = {'bold': 1, + 'underline': 4, + 'blink': 5, + 'reverse': 7, + 'concealed': 8} + + colors = ['black', 'red', 'green', 'yellow', + 'blue', 'magenta', 'cyan', 'white'] + + _ansi_fgcolors = dict(zip(colors, range(30, 38))) + _ansi_bgcolors = dict(zip(colors, range(40, 48))) + + __borg_state = {} + + def __init__(self, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr): + ''' + Initializes a Console instance. + @param stdin: The console standard input. + @type stdin: file object + @param stdout: The console standard output. + @type stdout: file object + ''' + self.__dict__ = self.__borg_state + self._stdout = stdout + self._stdin = stdin + self._stderr = stderr + self.prefs = Prefs() + + # Public methods + + def escape(self, sequence, reply_terminator=None): + ''' + Sends an escape sequence to the console, and reads the reply terminated + by reply_terminator. If reply_terminator is not specified, the reply + will not be read. + @type sequence: str + @param reply_terminator: The expected end-of-reply marker. + @type reply_terminator: str + ''' + attributes = tcgetattr(self._stdin) + tty.setraw(self._stdin) + try: + self.raw_write(self._escape + sequence) + if reply_terminator is not None: + reply = '' + while reply[-len(reply_terminator):] != reply_terminator: + reply += self._stdin.read(1) + finally: + tcsetattr(self._stdin, TCSADRAIN, attributes) + if reply_terminator is not None: + reply = reply[:-len(reply_terminator)] + reply = reply.replace(self._escape, '').split(';') + return reply + + def get_width(self): + ''' + Returns the console width, or maximum width if we are not a terminal + device. + ''' + try: + winsize = struct.pack("HHHH", 0, 0, 0, 0) + winsize = ioctl(self._stdout.fileno(), TIOCGWINSZ, winsize) + width = struct.unpack("HHHH", winsize)[1] + except IOError: + width = self._max_width + else: + if width > self._max_width: + width = self._max_width + + return width + + def get_cursor_xy(self): + ''' + Get the current text cursor x, y coordinates. + ''' + coords = [int(coord) for coord in self.escape("6n", "R")] + coords.reverse() + return coords + + def set_cursor_xy(self, xpos, ypos): + ''' + Set the cursor x, y coordinates. + @param xpos: The x coordinate of the cursor. + @type xpos: int + @param ypos: The y coordinate of the cursor. + @type ypos: int + ''' + self.escape("%d;%dH" % (ypos, xpos)) + + def raw_write(self, text, output=sys.stdout): + ''' + Raw console printing function. + @param text: The text to print. + @type text: str + ''' + output.write(text) + output.flush() + + def display(self, text, no_lf=False, error=False): + ''' + Display a text with a default style. + @param text: Text to display + @type text: str + @param no_lf: Do not display a line feed. + @type no_lf: bool + ''' + text = self.render_text(text) + + if error: + output = self._stderr + else: + output = self._stdout + + self.raw_write(text, output=output) + if not no_lf: + self.raw_write('\n', output=output) + + def epy_write(self, text): + ''' + Renders and print and epytext-formatted text on the console. + ''' + text = self.dedent(text) + try: + dom_tree = epydoc.markup.epytext.parse(text, None) + except NameError: + # epydoc not installed, strip markup + dom_tree = text + dom_tree = dom_tree.replace("B{", "") + dom_tree = dom_tree.replace("I{", "") + dom_tree = dom_tree.replace("C{", "") + dom_tree = dom_tree.replace("}", "") + dom_tree += "\n" + except: + self.display(text) + raise + text = self.render_domtree(dom_tree) + # We need to remove the last line feed, but there might be + # escape characters after it... + clean_text = '' + for index in range(1, len(text)): + if text[-index] == '\n': + clean_text = text[:-index] + if index != 1: + clean_text += text[-index+1:] + break + else: + clean_text = text + self.raw_write(clean_text) + + def indent(self, text, margin=2): + ''' + Indents text by margin space. + @param text: The text to be indented. + @type text: str + ''' + output = '' + for line in text.split('\n'): + output += margin * ' ' + line + '\n' + return output + + def dedent(self, text): + ''' + A convenience function to easily write multiline text blocks that + will be later assembled in to a unique epytext string. + It removes heading newline chars and common indentation. + ''' + for i in range(len(text)): + if text[i] != '\n': + break + text = text[i:] + text = textwrap.dedent(text) + text = '\n' * i + text + + return text + + def render_text(self, text, fgcolor=None, bgcolor=None, styles=None, + open_end=False, todefault=False): + ''' + Renders some text with ANSI console colors and attributes. + @param fgcolor: ANSI color to use for text: + black, red, green, yellow, blue, magenta. cyan. white + @type fgcolor: str + @param bgcolor: ANSI color to use for background: + black, red, green, yellow, blue, magenta. cyan. white + @type bgcolor: str + @param styles: List of ANSI styles to use: + bold, underline, blink, reverse, concealed + @type styles: list of str + @param open_end: Do not reset text style at the end ot the output. + @type open_end: bool + @param todefault: Instead of resetting style at the end of the + output, reset to default color. Only if not open_end. + @type todefault: bool + ''' + if self.prefs['color_mode'] and self._stdout.isatty(): + if fgcolor is None: + if self.prefs['color_default']: + fgcolor = self.prefs['color_default'] + if fgcolor is not None: + text = self._ansi_format % (self._ansi_fgcolors[fgcolor], text) + if bgcolor is not None: + text = self._ansi_format % (self._ansi_bgcolors[bgcolor], text) + if styles is not None: + for style in styles: + text = self._ansi_format % (self._ansi_styles[style], text) + if not open_end: + text += self._ansi_reset + if todefault and fgcolor is not None: + if self.prefs['color_default']: + text += self._ansi_format \ + % (self._ansi_fgcolors[ + self.prefs['color_default']], '') + return text + + def wordwrap(self, text, indent=0, startindex=0, splitchars=''): + ''' + Word-wrap the given string. I.e., add newlines to the string such + that any lines that are longer than terminal width or max_width + are broken into shorter lines (at the first whitespace sequence that + occurs before the limit. If the given string contains newlines, they + will I{not} be removed. Any lines that begin with whitespace will not + be wordwrapped. + + This version takes into account ANSI escape characters: + - stop escape sequence styling at the end of a split line + - start it again on the next line if needed after the indent + - do not account for the length of the escape sequences when + wrapping + + @param indent: If specified, then indent each line by this number + of spaces. + @type indent: C{int} + @param startindex: If specified, then assume that the first line + is already preceeded by C{startindex} characters. + @type startindex: C{int} + @param splitchars: A list of non-whitespace characters which can + be used to split a line. (E.g., use '/\\' to allow path names + to be split over multiple lines.) + @rtype: C{str} + ''' + right = self.get_width() + if splitchars: + chunks = re.split(r'( +|\n|[^ \n%s]*[%s])' % + (re.escape(splitchars), re.escape(splitchars)), + text.expandtabs()) + else: + chunks = re.split(r'( +|\n)', text.expandtabs()) + result = [' '*(indent-startindex)] + charindex = max(indent, startindex) + current_style = '' + for chunknum, chunk in enumerate(chunks): + chunk_groups = re.split(self._re_ansi_seq, chunk) + chunk_text = '' + next_style = current_style + + for group in chunk_groups: + if re.match(self._re_ansi_seq, group) is None: + chunk_text += group + else: + next_style += group + + chunk_len = len(chunk_text) + if (charindex + chunk_len > right and charindex > 0) \ + or chunk == '\n': + result[-1] = result[-1].rstrip() + result.append(self.render_text( + '\n' + ' '*indent + current_style, open_end=True)) + charindex = indent + if chunk[:1] not in ('\n', ' '): + result.append(chunk) + charindex += chunk_len + else: + result.append(chunk) + charindex += chunk_len + + current_style = next_style.split(self._ansi_reset)[-1] + + return ''.join(result).rstrip()+'\n' + + def render_domtree(self, tree, indent=0, seclevel=0): + ''' + Convert a DOM document encoding epytext to an 8-bits ascii string with + ANSI formating for simpler styles. + + @param tree: A DOM document encoding of an epytext string. + @type tree: C{Element} + @param indent: The indentation for the string representation of + C{tree}. Each line of the returned string will begin with + C{indent} space characters. + @type indent: C{int} + @param seclevel: The section level that C{tree} appears at. This + is used to generate section headings. + @type seclevel: C{int} + @return: The formated string. + @rtype: C{string} + ''' + if isinstance(tree, six.string_types): + return tree + + if tree.tag == 'section': + seclevel += 1 + + # Figure out the child indent level. + if tree.tag == 'epytext': + cindent = indent + elif tree.tag == 'li' and tree.attribs.get('bullet'): + cindent = indent + 1 + len(tree.attribs.get('bullet')) + else: + cindent = indent + 2 + + variables = [self.render_domtree(c, cindent, seclevel) + for c in tree.children] + childstr = ''.join(variables) + + if tree.tag == 'para': + text = self.render_text(childstr) + text = self.wordwrap(text, indent)+'\n' + elif tree.tag == 'li': + # We should be able to use getAttribute here; but there's no + # convenient way to test if an element has an attribute.. + bullet = tree.attribs.get('bullet') or '-' + text = indent*' ' + bullet + ' ' + childstr.lstrip() + elif tree.tag == 'heading': + text = ((indent-2)*' ' + self.render_text( + childstr, styles=['bold'], todefault=True) \ + + '\n') + elif tree.tag == 'doctestblock': + lines = [(indent+2)*' '+line for line in childstr.split('\n')] + text = '\n'.join(lines) + '\n\n' + elif tree.tag == 'literalblock': + lines = [(indent+1)*' '+ self.render_text( + line, todefault=True) + for line in childstr.split('\n')] + text = '\n'.join(lines) + '\n\n' + elif tree.tag == 'fieldlist': + text = childstr + elif tree.tag == 'field': + numargs = 0 + while tree.children[numargs+1].tag == 'arg': + numargs += 1 + args = variables[1:1+numargs] + body = variables[1+numargs:] + text = (indent)*' '+'@'+variables[0] + if args: + text += '(' + ', '.join(args) + ')' + text = text + ':\n' + ''.join(body) + elif tree.tag == 'uri': + if len(variables) != 2: + raise ValueError('Bad URI ') + elif variables[0] == variables[1]: + text = self.render_text( + '%s' % variables[1], + 'blue', styles=['underline'], todefault=True) + else: + text = '%r<%s>' % (variables[0], variables[1]) + elif tree.tag == 'link': + if len(variables) != 2: + raise ValueError('Bad Link') + text = '%s' % variables[0] + elif tree.tag in ('olist', 'ulist'): + text = childstr.replace('\n\n', '\n')+'\n' + elif tree.tag == 'bold': + text = self.render_text( + childstr, styles=['bold'], todefault=True) + elif tree.tag == 'italic': + text = self.render_text( + childstr, styles=['underline'], todefault=True) + elif tree.tag == 'symbol': + text = '%s' \ + % epydoc.markup.epytext.SYMBOL_TO_PLAINTEXT.get( + childstr, childstr) + elif tree.tag == 'graph': + text = '<<%s graph: %s>>' \ + % (variables[0], ', '.join(variables[1:])) + else: + # Assume that anything else can be passed through. + text = self.render_text(childstr) + + return text diff --git a/configshell/log.py b/configshell/log.py new file mode 100644 index 0000000..e932aaa --- /dev/null +++ b/configshell/log.py @@ -0,0 +1,171 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +import inspect +import os +import time +import traceback + +from .console import Console +from .prefs import Prefs + +class Log(object): + ''' + Implements a file and console logger using python's logging facility. + Log levels are, in raising criticality: + - debug + - info + - warning + - error + - critical + It uses configshell's Prefs() backend for storing some of its parameters, + who can then be read/changed by other objects using Prefs() + ''' + __borg_state = {} + levels = ['critical', 'error', 'warning', 'info', 'debug'] + colors = {'critical': 'red', 'error': 'red', 'warning': 'yellow', + 'info': 'green', 'debug': 'blue'} + + def __init__(self, console_level=None, + logfile=None, file_level=None): + ''' + This class implements the Borg pattern. + @param console_level: Console log level, defaults to 'info' + @type console_level: str + @param logfile: Optional logfile. + @type logfile: str + @param file_level: File log level, defaults to 'debug'. + @type file_level: str + ''' + self.__dict__ = self.__borg_state + self.con = Console() + self.prefs = Prefs() + + if console_level: + self.prefs['loglevel_console'] = console_level + elif not self.prefs['loglevel_console']: + self.prefs['loglevel_console'] = 'info' + + if file_level: + self.prefs['loglevel_file'] = file_level + elif not self.prefs['loglevel_file']: + self.prefs['loglevel_file'] = 'debug' + + if logfile: + self.prefs['logfile'] = logfile + + # Private methods + + def _append(self, msg, level): + ''' + Just appends the message to the logfile if it exists, prefixing it with + the current time and level. + @param msg: The message to log + @type msg: str + @param level: The debug level to prefix the message with. + @type level: str + ''' + date_fields = time.localtime() + date = "%d-%02d-%02d %02d:%02d:%02d" \ + % (date_fields[0], date_fields[1], date_fields[2], + date_fields[3], date_fields[4], date_fields[5]) + + if self.prefs['logfile']: + path = os.path.expanduser(self.prefs['logfile']) + handle = open(path, 'a') + try: + handle.write("[%s] %s %s\n" % (level, date, msg)) + finally: + handle.close() + + def _log(self, level, msg): + ''' + Do the actual logging. + @param level: The log level of the message. + @type level: str + @param msg: The message to log. + @type msg: str + ''' + if self.levels.index(self.prefs['loglevel_file']) \ + >= self.levels.index(level): + self._append(msg, level.upper()) + + if self.levels.index(self.prefs['loglevel_console']) \ + >= self.levels.index(level): + if self.prefs["color_mode"]: + msg = self.con.render_text(msg, self.colors[level]) + else: + msg = "%s: %s" % (level.capitalize(), msg) + error = False + if self.levels.index(level) <= self.levels.index('error'): + error = True + self.con.display(msg, error=error) + + # Public methods + + def debug(self, msg): + ''' + Logs a debug message. + @param msg: The message to log. + @type msg: str + ''' + caller = inspect.stack()[1] + msg = "%s:%d %s() %s" % (caller[1], caller[2], caller[3], msg) + self._log('debug', msg) + + def exception(self, msg=None): + ''' + Logs an error message and dumps a full stack trace. + @param msg: The message to log. + @type msg: str + ''' + trace = traceback.format_exc().rstrip() + if msg: + trace += '\n%s' % msg + self._log('error', trace) + + def info(self, msg): + ''' + Logs an info message. + @param msg: The message to log. + @type msg: str + ''' + self._log('info', msg) + + def warning(self, msg): + ''' + Logs a warning message. + @param msg: The message to log. + @type msg: str + ''' + self._log('warning', msg) + + def error(self, msg): + ''' + Logs an error message. + @param msg: The message to log. + @type msg: str + ''' + self._log('error', msg) + + def critical(self, msg): + ''' + Logs a critical message. + @param msg: The message to log. + @type msg: str + ''' + self._log('critical', msg) diff --git a/configshell/node.py b/configshell/node.py new file mode 100644 index 0000000..848c667 --- /dev/null +++ b/configshell/node.py @@ -0,0 +1,1865 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +import inspect +import re +import six + +class ExecutionError(Exception): + pass + +class ConfigNode(object): + ''' + The ConfigNode class defines a common skeleton to be used by specific + implementation. It is "purely virtual" (sorry for using non-pythonic + vocabulary there ;-) ). + ''' + _path_separator = '/' + _path_current = '.' + _path_previous = '..' + + ui_type_method_prefix = "ui_type_" + ui_command_method_prefix = "ui_command_" + ui_complete_method_prefix = "ui_complete_" + ui_setgroup_method_prefix = "ui_setgroup_" + ui_getgroup_method_prefix = "ui_getgroup_" + + help_intro = ''' + GENERALITIES + ============ + This is a shell in which you can create, delete and configure + configuration objects. + + The available commands depend on the current path or target + path you want to run a command in: different path have + different sets of available commands, i.e. a path pointing at + an iscsi target will not have the same availaible commands as, + say, a path pointing at a storage object. + + The prompt that starts each command line indicates your + current path. Alternatively (useful if the prompt displays + an abbreviated path to save space), you can run the + B{pwd} command to display the complete current path. + + Navigating the tree is done using the B{cd} command. Without + any argument, B{cd} will present you wil the full objects + tree. Just use arrows to select the destination path, and + enter will get you there. Please try B{help cd} for navigation + tips. + + COMMAND SYNTAX + ============== + Commands are built using the following syntax: + + [I{TARGET_PATH}] B{COMMAND_NAME} [I{OPTIONS}] + + The I{TARGET_PATH} indicates the path to run the command from. + If ommited, the command will be run from your current path. + + The I{OPTIONS} depend on the command. Please use B{help + COMMAND} to get more information. + ''' + + def __init__(self, name, parent=None, shell=None): + ''' + @param parent: The parent ConfigNode of the new object. If None, then + the ConfigNode will be a root node. + @type parent: ConfigNode or None + @param shell: The shell to attach a root node to. + @type shell: ConfigShell + ''' + self._name = name + self._children = set([]) + if parent is None: + if shell is None: + raise ValueError("A root ConfigNode must have a shell.") + else: + self._parent = None + self._shell = shell + shell.attach_root_node(self) + else: + if shell is None: + self._parent = parent + self._shell = None + else: + raise ValueError("A non-root ConfigNode can't have a shell.") + + if self._parent is not None: + for sibling in self._parent._children: + if sibling.name == name: + raise ValueError("Name '%s' already used by a sibling." + % self._name) + self._parent._children.add(self) + + self._configuration_groups = {} + + self.define_config_group_param( + 'global', 'tree_round_nodes', 'bool', + 'Tree node display style.') + self.define_config_group_param( + 'global', 'tree_status_mode', 'bool', + 'Whether or not to display status in tree.') + self.define_config_group_param( + 'global', 'tree_max_depth', 'number', + 'Maximum depth of displayed node tree.') + self.define_config_group_param( + 'global', 'tree_show_root', 'bool', + 'Whether or not to display tree root.') + self.define_config_group_param( + 'global', 'color_mode', 'bool', + 'Console color display mode.') + self.define_config_group_param( + 'global', 'loglevel_console', 'loglevel', + 'Log level for messages going to the console.') + self.define_config_group_param( + 'global', 'loglevel_file', 'loglevel', + 'Log level for messages going to the log file.') + self.define_config_group_param( + 'global', 'logfile', 'string', + 'Logfile to use.') + self.define_config_group_param( + 'global', 'color_default', 'colordefault', + 'Default text display color.') + self.define_config_group_param( + 'global', 'color_path', 'color', + 'Color to use for path completions') + self.define_config_group_param( + 'global', 'color_command', 'color', + 'Color to use for command completions.') + self.define_config_group_param( + 'global', 'color_parameter', 'color', + 'Color to use for parameter completions.') + self.define_config_group_param( + 'global', 'color_keyword', 'color', + 'Color to use for keyword completions.') + self.define_config_group_param( + 'global', 'prompt_length', 'number', + 'Max length of the shell prompt path, 0 for infinite.') + + if self.shell.prefs['bookmarks'] is None: + self.shell.prefs['bookmarks'] = {} + + # User interface types + + def ui_type_number(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for number parameter type. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or [] if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok against the type. + ''' + if reverse: + if value is not None: + return str(value) + else: + return 'n/a' + + type_enum = [] + syntax = "NUMBER" + if value is None: + if enum: + return type_enum + else: + return syntax + elif not value: + return None + else: + try: + value = int(value) + except ValueError: + raise ValueError("Syntax error, '%s' is not a %s." + % (value, syntax)) + else: + return value + + def ui_type_string(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for string parameter type. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or [] if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok against the type. + ''' + if reverse: + if value is not None: + return value + else: + return 'n/a' + + type_enum = [] + syntax = "STRING_OF_TEXT" + if value is None: + if enum: + return type_enum + else: + return syntax + elif not value: + return None + else: + try: + value = str(value) + except ValueError: + raise ValueError("Syntax error, '%s' is not a %s." + % (value, syntax)) + else: + return value + + def ui_type_bool(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for boolean parameter type. Valid values are + either 'true' or 'false'. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or None if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok againts the type. + ''' + if reverse: + if value: + return 'true' + else: + return 'false' + type_enum = ['true', 'false'] + syntax = '|'.join(type_enum) + if value is None: + if enum: + return type_enum + else: + return syntax + elif value.lower() == 'true': + return True + elif value.lower() == 'false': + return False + else: + raise ValueError("Syntax error, '%s' is not %s." + % (value, syntax)) + + def ui_type_loglevel(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for log level parameter type. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or None if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok againts the type. + ''' + if reverse: + if value is not None: + return value + else: + return 'n/a' + + type_enum = self.shell.log.levels + syntax = '|'.join(type_enum) + if value is None: + if enum: + return type_enum + else: + return syntax + elif value in type_enum: + return value + else: + raise ValueError("Syntax error, '%s' is not %s" + % (value, syntax)) + + def ui_type_color(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for color parameter type. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or None if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok againts the type. + ''' + if reverse: + if value is not None: + return value + else: + return 'default' + + type_enum = self.shell.con.colors + ['default'] + syntax = '|'.join(type_enum) + if value is None: + if enum: + return type_enum + else: + return syntax + elif not value or value == 'default': + return None + elif value in type_enum: + return value + else: + raise ValueError("Syntax error, '%s' is not %s" + % (value, syntax)) + + def ui_type_colordefault(self, value=None, enum=False, reverse=False): + ''' + UI parameter type helper for default color parameter type. + @param value: Value to check against the type. + @type value: anything + @param enum: Has a meaning only if value is omitted. If set, returns + a list of the possible values for the type, or None if this is not + possible. If not set, returns a text description of the type format. + @type enum: bool + @param reverse: If set, translates an internal value to its UI + string representation. + @type reverse: bool + @return: c.f. parameter enum description. + @rtype: str|list|None + @raise ValueError: If the value does not check ok againts the type. + ''' + if reverse: + if value is not None: + return value + else: + return 'none' + + type_enum = self.shell.con.colors + ['none'] + syntax = '|'.join(type_enum) + if value is None: + if enum: + return type_enum + else: + return syntax + elif not value or value == 'none': + return None + elif value in type_enum: + return value + else: + raise ValueError("Syntax error, '%s' is not %s" + % (value, syntax)) + + + # User interface get/set methods + + def ui_setgroup_global(self, parameter, value): + ''' + This is the backend method for setting parameters in configuration + group 'global'. It simply uses the Prefs() backend to store the global + preferences for the shell. Some of these group parameters are shared + using the same Prefs() object by the Log() and Console() classes, so + this backend should not be changed without taking this into + consideration. + + The parameters getting to us have already been type-checked and casted + by the type-check methods registered in the config group via the ui set + command, and their existence in the group has also been checked. Thus + our job is minimal here. Also, it means that overhead when called with + generated arguments (as opposed to user-supplied) gets minimal + overhead, and allows setting new parameters without error. + + @param parameter: The parameter to set. + @type parameter: str + @param value: The value + @type value: arbitrary + ''' + self.shell.prefs[parameter] = value + + def ui_getgroup_global(self, parameter): + ''' + This is the backend method for getting configuration parameters out of + the B{global} configuration group. It gets the values from the Prefs() + backend. Eventual casting to str for UI display is handled by the ui + get command, for symmetry with the pendant ui_setgroup method. + Existence of the parameter in the group should have already been + checked by the ui get command, so we go blindly about this. This might + allow internal client code to get a None value if the parameter does + not exist, as supported by Prefs(). + + @param parameter: The parameter to get the value of. + @type parameter: str + @return: The parameter's value + @rtype: arbitrary + ''' + return self.shell.prefs[parameter] + + def ui_eval_param(self, ui_value, type, default): + ''' + Evaluates a user-provided parameter value using a given type helper. + If the parameter value is None, the default will be returned. If the + ui_value does not check out with the type helper, and execution error + will be raised. + + @param ui_value: The user provided parameter value. + @type ui_value: str + @param type: The ui_type to be used + @type type: str + @param default: The default value to return. + @type default: any + @return: The evaluated parameter value. + @rtype: depends on type + @raise ExecutionError: If evaluation fails. + ''' + type_method = self.get_type_method(type) + if ui_value is None: + return default + else: + try: + value = type_method(ui_value) + except ValueError as msg: + raise ExecutionError(msg) + else: + return value + + def get_type_method(self, type): + ''' + Returns the type helper method matching the type name. + ''' + return getattr(self, "%s%s" % (self.ui_type_method_prefix, type)) + + # User interface commands + + def ui_command_set(self, group=None, **parameter): + ''' + Sets one or more configuration parameters in the given group. + The B{global} group contains all global CLI preferences. + Other groups are specific to the current path. + + Run with no parameter nor group to list all available groups, or + with just a group name to list all available parameters within that + group. + + Example: B{set global color_mode=true loglevel_console=info} + + SEE ALSO + ======== + get + ''' + if group is None: + self.shell.con.epy_write(''' + AVAILABLE CONFIGURATION GROUPS + ============================== + %s + ''' % ' '.join(self.list_config_groups())) + elif not parameter: + if group not in self.list_config_groups(): + raise ExecutionError("Unknown configuration group: %s" % group) + + section = "%s CONFIG GROUP" % group.upper() + underline1 = ''.ljust(len(section), '=') + parameters = '' + for p_name in self.list_group_params(group, writable=True): + p_def = self.get_group_param(group, p_name) + type_method = self.get_type_method(p_def['type']) + p_name = "%s=I{%s}" % (p_def['name'], p_def['type']) + underline2 = ''.ljust(len(p_name), '-') + parameters += '%s\n%s\n%s\n\n' \ + % (p_name, underline2, p_def['description']) + self.shell.con.epy_write('''%s\n%s\n%s\n''' + % (section, underline1, parameters)) + + elif group not in self.list_config_groups(): + raise ExecutionError("Unknown configuration group: %s" % group) + + for param, value in six.iteritems(parameter): + if param not in self.list_group_params(group): + raise ExecutionError("Unknown parameter %s in group '%s'." + % (param, group)) + + p_def = self.get_group_param(group, param) + type_method = self.get_type_method(p_def['type']) + if not p_def['writable']: + raise ExecutionError("Parameter %s is read-only." % param) + + try: + value = type_method(value) + except ValueError as msg: + raise ExecutionError("Not setting %s! %s" % (param, msg)) + + group_setter = self.get_group_setter(group) + group_setter(param, value) + group_getter = self.get_group_getter(group) + value = group_getter(param) + value = type_method(value, reverse=True) + self.shell.con.display("Parameter %s is now '%s'." % (param, value)) + + def ui_complete_set(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command set. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + completions = [] + + self.shell.log.debug("Called with params=%s, text='%s', current='%s'" + % (str(parameters), text, current_param)) + + if current_param == 'group': + completions = [group for group in self.list_config_groups() + if group.startswith(text)] + elif 'group' in parameters: + group = parameters['group'] + if group in self.list_config_groups(): + group_params = self.list_group_params(group, writable=True) + if current_param in group_params: + p_def = self.get_group_param(group, current_param) + type_method = self.get_type_method(p_def['type']) + type_enum = type_method(enum=True) + if type_enum is not None: + type_enum = [item for item in type_enum + if item.startswith(text)] + completions.extend(type_enum) + else: + group_params = ([param + '=' for param in group_params + if param.startswith(text) + if param not in parameters]) + if group_params: + completions.extend(group_params) + + if len(completions) == 1 and not completions[0].endswith('='): + completions = [completions[0] + ' '] + + self.shell.log.debug("Returning completions %s." % str(completions)) + return completions + + def ui_command_get(self, group=None, *parameter): + ''' + Gets the value of one or more configuration parameters in the given + group. + + Run with no parameter nor group to list all available groups, or + with just a group name to list all available parameters within that + group. + + Example: B{get global color_mode loglevel_console} + + SEE ALSO + ======== + set + ''' + if group is None: + self.shell.con.epy_write(''' + AVAILABLE CONFIGURATION GROUPS + ============================== + %s + ''' % ' '.join(self.list_config_groups())) + elif not parameter: + if group not in self.list_config_groups(): + raise ExecutionError("Unknown configuration group: %s" % group) + + section = "%s CONFIG GROUP" % group.upper() + underline1 = ''.ljust(len(section), '=') + parameters = '' + params = [self.get_group_param(group, p_name) + for p_name in self.list_group_params(group)] + for p_def in params: + group_getter = self.get_group_getter(group) + value = group_getter(p_def['name']) + type_method = self.get_type_method(p_def['type']) + value = type_method(value, reverse=True) + param = "%s=%s" % (p_def['name'], value) + if p_def['writable'] is False: + param += " [ro]" + underline2 = ''.ljust(len(param), '-') + parameters += '%s\n%s\n%s\n\n' \ + % (param, underline2, p_def['description']) + + self.shell.con.epy_write('''%s\n%s\n%s\n''' + % (section, underline1, parameters)) + + elif group not in self.list_config_groups(): + raise ExecutionError("Unknown configuration group: %s" % group) + + for param in parameter: + if param not in self.list_group_params(group): + raise ExecutionError("No parameter '%s' in group '%s'." + % (param, group)) + + self.shell.log.debug("About to get the parameter's value.") + group_getter = self.get_group_getter(group) + value = group_getter(param) + p_def = self.get_group_param(group, param) + type_method = self.get_type_method(p_def['type']) + value = type_method(value, reverse=True) + if p_def['writable']: + writable = "" + else: + writable = "[ro]" + self.shell.con.display("%s=%s %s" + % (param, value, writable)) + + def ui_complete_get(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command get. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + completions = [] + + self.shell.log.debug("Called with params=%s, text='%s', current='%s'" + % (str(parameters), text, current_param)) + + if current_param == 'group': + completions = [group for group in self.list_config_groups() + if group.startswith(text)] + elif 'group' in parameters: + group = parameters['group'] + if group in self.list_config_groups(): + group_params = ([param + for param in self.list_group_params(group) + if param.startswith(text) + if param not in parameters]) + if group_params: + completions.extend(group_params) + + if len(completions) == 1 and not completions[0].endswith('='): + completions = [completions[0] + ' '] + + self.shell.log.debug("Returning completions %s." % str(completions)) + return completions + + def ui_command_ls(self, path=None, depth=None): + ''' + Display either the nodes tree relative to path or to the current node. + + PARAMETERS + ========== + + I{path} + ------- + The I{path} to display the nodes tree of. Can be an absolute path, a + relative path or a bookmark. + + I{depth} + -------- + The I{depth} parameter limits the maximum depth of the tree to display. + If set to 0, then the complete tree will be displayed (the default). + + SEE ALSO + ======== + cd bookmarks + ''' + try: + target = self.get_node(path) + except ValueError as msg: + raise ExecutionError(str(msg)) + + if depth is None: + depth = self.shell.prefs['tree_max_depth'] + try: + depth = int(depth) + except ValueError: + raise ExecutionError('The tree depth must be a number.') + + if depth == 0: + depth = None + tree = self._render_tree(target, depth=depth) + self.shell.con.display(tree) + + def _render_tree(self, root, margin=None, depth=None, do_list=False): + ''' + Renders an ascii representation of a tree of ConfigNodes. + @param root: The root node of the tree + @type root: ConfigNode + @param margin: Format of the left margin to use for children. + True results in a pipe, and False results in no pipe. + Used for recursion only. + @type margin: list + @param depth: The maximum depth of nodes to display, None means + infinite. + @type depth: None or int + @param do_list: Return two lists, one with each line text + representation, the other with the corresponding paths. + @type do_list: bool + @return: An ascii tree representation or (lines, paths). + @rtype: str + ''' + lines = [] + paths = [] + + node_length = 2 + node_shift = 2 + level = root.path.rstrip('/').count('/') + if margin is None: + margin = [0] + root_call = True + else: + root_call = False + + if do_list: + color = None + elif not level % 3: + color = None + elif not (level - 1) % 3: + color = 'blue' + else: + color = 'magenta' + + if do_list: + styles = None + elif root_call: + styles = ['bold', 'underline'] + else: + styles = ['bold'] + + if do_list: + name = root.name + else: + name = self.shell.con.render_text(root.name, color, styles=styles) + name_len = len(root.name) + + (description, is_healthy) = root.summary() + if not description: + if is_healthy is True: + description = "OK" + elif is_healthy is False: + description = "ERROR" + else: + description = "..." + + description_len = len(description) + 3 + + if do_list: + summary = '[' + else: + summary = self.shell.con.render_text(' [', styles=['bold']) + + if is_healthy is True: + if do_list: + summary += description + else: + summary += self.shell.con.render_text(description, 'green') + elif is_healthy is False: + if do_list: + summary += description + else: + summary += self.shell.con.render_text(description, 'red', + styles=['bold']) + else: + summary += description + + if do_list: + summary += ']' + else: + summary += self.shell.con.render_text(']', styles=['bold']) + + def sorting_keys(s): + m = re.search(r'(.*?)(\d+$)', str(s)) + if m: + return (m.group(1), int(m.group(2))) + else: + return (str(s), 0) + + # Sort ending numbers numerically, so we get e.g. "lun1, lun2, lun10" + # instead of "lun1, lun10, lun2". + children = sorted(root.children, key=sorting_keys) + line = "" + + for pipe in margin[:-1]: + if pipe: + line = line + "|".ljust(node_shift) + else: + line = line + ''.ljust(node_shift) + + if self.shell.prefs['tree_round_nodes']: + node_char = 'o' + else: + node_char = '+' + line += node_char.ljust(node_length, '-') + line += ' ' + margin_len = len(line) + + pad = (self.shell.con.get_width() - 1 + - description_len + - margin_len + - name_len) * '.' + if not do_list: + pad = self.shell.con.render_text(pad, color) + + line += name + if self.shell.prefs['tree_status_mode']: + line += ' %s%s' % (pad, summary) + + lines.append(line) + paths.append(root.path) + + if root_call \ + and not self.shell.prefs['tree_show_root'] \ + and not do_list: + tree = '' + for child in children: + tree = tree + self._render_tree(child, [False], depth) + else: + tree = line + '\n' + if depth is None or depth > 0: + if depth is not None: + depth = depth - 1 + for i in range(len(children)): + margin.append(i<len(children)-1) + if do_list: + new_lines, new_paths = \ + self._render_tree(children[i], margin, depth, + do_list=True) + lines.extend(new_lines) + paths.extend(new_paths) + else: + tree = tree \ + + self._render_tree(children[i], margin, depth) + margin.pop() + + if root_call: + if do_list: + return (lines, paths) + else: + return tree[:-1] + else: + if do_list: + return (lines, paths) + else: + return tree + + + def ui_complete_ls(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command ls. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + if current_param == 'path': + (basedir, slash, partial_name) = text.rpartition('/') + basedir = basedir + slash + target = self.get_node(basedir) + names = [child.name for child in target.children] + completions = [] + for name in names: + num_matches = 0 + if name.startswith(partial_name): + num_matches += 1 + if num_matches == 1: + completions.append("%s%s/" % (basedir, name)) + else: + completions.append("%s%s" % (basedir, name)) + if len(completions) == 1: + if not self.get_node(completions[0]).children: + completions[0] = completions[0].rstrip('/') + ' ' + + # Bookmarks + bookmarks = ['@' + bookmark for bookmark + in self.shell.prefs['bookmarks'] + if ('@' + bookmark).startswith(text)] + self.shell.log.debug("Found bookmarks %s." % str(bookmarks)) + if bookmarks: + completions.extend(bookmarks) + + self.shell.log.debug("Completions are %s." % str(completions)) + return completions + + elif current_param == 'depth': + if text: + try: + int(text.strip()) + except ValueError: + self.shell.log.debug("Text is not a number.") + return [] + return [ text + number for number + in [str(num) for num in range(10)] + if (text + number).startswith(text)] + + def ui_command_cd(self, path=None): + ''' + Change current path to path. + + The path is constructed just like a unix path, with B{/} as separator + character, B{.} for the current node, B{..} for the parent node. + + Suppose the nodes tree looks like this:: + +-/ + +-a0 (1) + | +-b0 (*) + | +-c0 + +-a1 (3) + +-b0 + +-c0 + +-d0 (2) + + Suppose the current node is the one marked (*) at the beginning of all + the following examples: + - B{cd ..} takes you to the node marked (1) + - B{cd .} makes you stay in (*) + - B{cd /a1/b0/c0/d0} takes you to the node marked (2) + - B{cd ../../a1} takes you to the node marked (3) + - B{cd /a1} also takes you to the node marked (3) + - B{cd /} takes you to the root node B{/} + - B{cd /a0/b0/./c0/../../../a1/.} takes you to the node marked (3) + + You can also navigate the path history with B{<} and B{>}: + - B{cd <} takes you back one step in the path history + - B{cd >} takes you one step forward in the path history + + SEE ALSO + ======== + ls cd + ''' + self.shell.log.debug("Changing current node to '%s'." % path) + + if self.shell.prefs['path_history'] is None: + self.shell.prefs['path_history'] = [self.path] + self.shell.prefs['path_history_index'] = 0 + + # Go back in history to the last existing path + if path == '<': + if self.shell.prefs['path_history_index'] == 0: + self.shell.log.info("Reached begining of path history.") + return self + exists = False + while not exists: + if self.shell.prefs['path_history_index'] > 0: + self.shell.prefs['path_history_index'] = \ + self.shell.prefs['path_history_index'] - 1 + index = self.shell.prefs['path_history_index'] + path = self.shell.prefs['path_history'][index] + try: + target_node = self.get_node(path) + except ValueError: + pass + else: + exists = True + else: + path = '/' + self.shell.prefs['path_history_index'] = 0 + self.shell.prefs['path_history'][0] = '/' + exists = True + self.shell.log.info('Taking you back to %s.' % path) + return self.get_node(path) + + # Go forward in history + if path == '>': + if self.shell.prefs['path_history_index'] == \ + len(self.shell.prefs['path_history']) - 1: + self.shell.log.info("Reached the end of path history.") + return self + exists = False + while not exists: + if self.shell.prefs['path_history_index'] \ + < len(self.shell.prefs['path_history']) - 1: + self.shell.prefs['path_history_index'] = \ + self.shell.prefs['path_history_index'] + 1 + index = self.shell.prefs['path_history_index'] + path = self.shell.prefs['path_history'][index] + try: + target_node = self.get_node(path) + except ValueError: + pass + else: + exists = True + else: + path = self.path + self.shell.prefs['path_history_index'] \ + = len(self.shell.prefs['path_history']) + self.shell.prefs['path_history'].append(path) + exists = True + self.shell.log.info('Taking you back to %s.' % path) + return self.get_node(path) + + # Use an urwid walker to select the path + if path is None: + lines, paths = self._render_tree(self.get_root(), do_list=True) + start_pos = paths.index(self.path) + selected = self._lines_walker(lines, start_pos=start_pos) + path = paths[selected] + + # Normal path + try: + target_node = self.get_node(path) + except ValueError as msg: + raise ExecutionError(str(msg)) + + index = self.shell.prefs['path_history_index'] + if target_node.path != self.shell.prefs['path_history'][index]: + # Truncate the hostory to retain current path as last one + self.shell.prefs['path_history'] = \ + self.shell.prefs['path_history'][:index+1] + # Append the new path and update the index + self.shell.prefs['path_history'].append(target_node.path) + self.shell.prefs['path_history_index'] = index + 1 + self.shell.log.debug("After cd, path history is: %s, index is %d" + % (str(self.shell.prefs['path_history']), + self.shell.prefs['path_history_index'])) + return target_node + + def _lines_walker(self, lines, start_pos): + ''' + Using the curses urwid library, displays all lines passed as argument, + and after allowing selection of one line using up, down and enter keys, + returns its index. + @param lines: The lines to display and select from. + @type lines: list of str + @param start_pos: The index of the line to select initially. + @type start_pos: int + @return: the index of the selected line. + @rtype: int + ''' + import urwid + + palette = [('header', 'white', 'black'), + ('reveal focus', 'black', 'yellow', 'standout')] + + content = urwid.SimpleListWalker( + [urwid.AttrMap(w, None, 'reveal focus') + for w in [urwid.Text(line) for line in lines]]) + + listbox = urwid.ListBox(content) + frame = urwid.Frame(listbox) + + def handle_input(input, raw): + for key in input: + widget, pos = content.get_focus() + if key == 'up': + if pos > 0: + content.set_focus(pos-1) + elif key == 'down': + try: + content.set_focus(pos+1) + except IndexError: + pass + elif key == 'enter': + raise urwid.ExitMainLoop() + + content.set_focus(start_pos) + loop = urwid.MainLoop(frame, palette, input_filter=handle_input) + loop.run() + return listbox.focus_position + + def ui_complete_cd(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command cd. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + if current_param == 'path': + completions = self.ui_complete_ls(parameters, text, current_param) + completions.extend([nav for nav in ['<', '>'] + if nav.startswith(text)]) + return completions + + def ui_command_help(self, topic=None): + ''' + Displays the manual page for a topic, or list available topics. + ''' + commands = self.list_commands() + if topic is None: + msg = self.shell.con.dedent(self.help_intro) + msg += self.shell.con.dedent(''' + + AVAILABLE COMMANDS + ================== + The following commands are available in the + current path: + + ''') + for command in commands: + msg += " - %s\n" % self.get_command_syntax(command)[0] + self.shell.con.epy_write(msg) + return + + if topic not in commands: + raise ExecutionError("Cannot find help topic %s." % topic) + + syntax, comments, defaults = self.get_command_syntax(topic) + msg = self.shell.con.dedent(''' + SYNTAX + ====== + %s + + ''' % syntax) + for comment in comments: + msg += comment + '\n' + + if defaults: + msg += self.shell.con.dedent(''' + DEFAULT VALUES + ============== + %s + + ''' % defaults) + msg += self.shell.con.dedent(''' + DESCRIPTION + =========== + ''') + msg += self.get_command_description(topic) + msg += "\n" + self.shell.con.epy_write(msg) + + def ui_complete_help(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command help. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + if current_param == 'topic': + # TODO Add other types of topics + topics = self.list_commands() + completions = [topic for topic in topics + if topic.startswith(text)] + else: + completions = [] + + if len(completions) == 1: + return [completions[0] + ' '] + else: + return completions + + def ui_command_exit(self): + ''' + Exits the command line interface. + ''' + return 'EXIT' + + def ui_command_bookmarks(self, action, bookmark=None): + ''' + Manage your bookmarks. + + Note that you can also access your bookmarks with the + B{cd} command. For instance, the following commands + are equivalent: + - B{cd mybookmark} + - C{bookmarks go mybookmark} + + You can also use bookmarks anywhere where you would use + a normal path: + - B{@mybookmark ls} would perform the B{ls} command + in the bookmarked path. + - B{ls @mybookmark} would show you the objects tree from + the bookmarked path. + + + PARAMETERS + ========== + + I{action} + --------- + The I{action} is one of: + - B{add} adds the current path to your bookmarks. + - B{del} deletes a bookmark. + - B{go} takes you to a bookmarked path. + - B{show} shows you all your bookmarks. + + I{bookmark} + ----------- + This is the name of the bookmark. + + SEE ALSO + ======== + ls cd + ''' + if action == 'add' and bookmark: + if bookmark in self.shell.prefs['bookmarks']: + raise ExecutionError("Bookmark %s already exists." % bookmark) + + self.shell.prefs['bookmarks'][bookmark] = self.path + # No way Prefs is going to account for that :-( + self.shell.prefs.save() + self.shell.log.info("Bookmarked %s as %s." + % (self.path, bookmark)) + elif action == 'del' and bookmark: + if bookmark not in self.shell.prefs['bookmarks']: + raise ExecutionError("No such bookmark %s." % bookmark) + + del self.shell.prefs['bookmarks'][bookmark] + # No way Prefs is going to account for that deletion + self.shell.prefs.save() + self.shell.log.info("Deleted bookmark %s." % bookmark) + elif action == 'go' and bookmark: + if bookmark not in self.shell.prefs['bookmarks']: + raise ExecutionError("No such bookmark %s." % bookmark) + return self.ui_command_cd( + self.shell.prefs['bookmarks'][bookmark]) + elif action == 'show': + bookmarks = self.shell.con.dedent(''' + BOOKMARKS + ========= + + ''') + if not self.shell.prefs['bookmarks']: + bookmarks += "No bookmarks yet.\n" + else: + for (bookmark, path) \ + in six.iteritems(self.shell.prefs['bookmarks']): + if len(bookmark) == 1: + bookmark += '\0' + underline = ''.ljust(len(bookmark), '-') + bookmarks += "%s\n%s\n%s\n\n" % (bookmark, underline, path) + self.shell.con.epy_write(bookmarks) + else: + raise ExecutionError("Syntax error, see 'help bookmarks'.") + + def ui_complete_bookmarks(self, parameters, text, current_param): + ''' + Parameter auto-completion method for user command bookmarks. + @param parameters: Parameters on the command line. + @type parameters: dict + @param text: Current text of parameter being typed by the user. + @type text: str + @param current_param: Name of parameter to complete. + @type current_param: str + @return: Possible completions + @rtype: list of str + ''' + if current_param == 'action': + completions = [action for action in ['add', 'del', 'go', 'show'] + if action.startswith(text)] + elif current_param == 'bookmark': + if 'action' in parameters: + if parameters['action'] not in ['show', 'add']: + completions = [mark for mark + in self.shell.prefs['bookmarks'] + if mark.startswith(text)] + else: + completions = [] + + if len(completions) == 1: + return [completions[0] + ' '] + else: + return completions + + def ui_command_pwd(self): + ''' + Displays the current path. + + SEE ALSO + ======== + ls cd + ''' + self.shell.con.display(self.path) + + # Private methods + + def __str__(self): + if self.is_root(): + return '/' + else: + return self.name + + def _get_parent(self): + ''' + Get this node's parent. + @return: The node's parent. + @rtype: ConfigNode + ''' + return self._parent + + def _get_name(self): + ''' + @return: The node's name. + @rtype: str + ''' + return self._name + + def _set_name(self, name): + ''' + Sets the node's name. + ''' + self._name = name + + def _get_path(self): + ''' + @returns: The absolute path for this node. + @rtype: str + ''' + subpath = self._path_separator + self.name + if self.is_root(): + return self._path_separator + elif self._parent.is_root(): + return subpath + else: + return self._parent.path + subpath + + def _list_children(self): + ''' + Lists the children of this node. + @return: The set of children nodes. + @rtype: set of ConfigNode + ''' + return self._children + + def _get_shell(self): + ''' + Gets the shell attached to ConfigNode tree. + ''' + if self.is_root(): + return self._shell + else: + return self.get_root().shell + + # Public methods + + def summary(self): + ''' + Returns a tuple with a status/description string for this node and a + health flag, to be displayed along the node's name in object trees, + etc. + @returns: (description, is_healthy) + @rtype: (str, bool or None) + ''' + return ('', None) + + def execute_command(self, command, pparams=[], kparams={}): + ''' + Execute a user command on the node. This works by finding out which is + the support command method, using ConfigNode naming convention: + The support method's name is 'PREFIX_COMMAND', where PREFIX is defined + by ConfigNode.ui_command_method_prefix and COMMAND is the commands's + name as seen by the user. + @param command: Name of the command. + @type command: str + @param pparams: The positional parameters to use. + @type pparams: list + @param kparams: The keyword=value parameters to use. + @type kparams: dict + @return: The support method's return value. + See ConfigShell._execute_command() for expected return values and how + they are interpreted by ConfigShell. + @rtype: str or ConfigNode or None + ''' + self.shell.log.debug("Executing command %s " % command + + "with pparams %s " % pparams + + "and kparams %s." % kparams) + + if command in self.list_commands(): + method = self.get_command_method(command) + else: + raise ExecutionError("Command not found %s" % command) + + self.assert_params(method, pparams, kparams) + return method(*pparams, **kparams) + + def assert_params(self, method, pparams, kparams): + ''' + Checks that positional and keyword parameters match a method + definition, or raise an ExecutionError. + @param method: The method to check call signature against. + @type method: method + @param pparams: The positional parameters. + @type pparams: list + @param kparams: The keyword parameters. + @type kparams: dict + @raise ExecutionError: When the check fails. + ''' + spec = inspect.getargspec(method) + args = spec.args[1:] + pp = spec.varargs + kw = spec.keywords + + if spec.defaults is None: + nb_opt_params = 0 + else: + nb_opt_params = len(spec.defaults) + nb_max_params = len(args) + nb_min_params = nb_max_params - nb_opt_params + + req_params = args[:nb_min_params] + opt_params = args[nb_min_params:] + + unexpected_keywords = sorted(set(kparams) - set(args)) + missing_params = sorted(set(args[len(pparams):]) + - set(opt_params) + - set(kparams.keys())) + + nb_params = len(pparams) + len(kparams) + nb_standard_params = len(pparams) \ + + len([param for param in kparams if param in args]) + nb_extended_params = nb_params - nb_standard_params + + self.shell.log.debug("Min params: %d" % nb_min_params) + self.shell.log.debug("Max params: %d" % nb_max_params) + self.shell.log.debug("Required params: %s" % ", ".join(req_params)) + self.shell.log.debug("Optional params: %s" % ", ".join(opt_params)) + self.shell.log.debug("Got %s standard params." % nb_standard_params) + self.shell.log.debug("Got %s extended params." % nb_extended_params) + self.shell.log.debug("Variable positional params: %s" % pp) + self.shell.log.debug("Variable keyword params: %s" % kw) + + if len(missing_params) == 1: + raise ExecutionError( + "Missing required parameter %s" + % missing_params[0]) + elif missing_params: + raise ExecutionError( + "Missing required parameters %s" + % ", ".join("'%s'" % missing for missing in missing_params)) + + if spec.keywords is None: + if len(unexpected_keywords) == 1: + raise ExecutionError( + "Unexpected keyword parameter '%s'." + % unexpected_keywords[0]) + elif unexpected_keywords: + raise ExecutionError( + "Unexpected keyword parameters %s." + % ", ".join("'%s'" % kw for kw in unexpected_keywords)) + all_params = args[:len(pparams)] + all_params.extend(kparams.keys()) + for param in all_params: + if all_params.count(param) > 1: + raise ExecutionError( + "Duplicate parameter %s." + % param) + + if nb_opt_params == 0 \ + and nb_standard_params != nb_min_params \ + and pp is None: + raise ExecutionError( + "Got %d positionnal parameters, expected exactly %d." + % (nb_standard_params, nb_min_params)) + + if nb_standard_params > nb_max_params and pp is None: + raise ExecutionError( + "Got %d positionnal parameters, expected at most %d." + % (nb_standard_params, nb_max_params)) + + def list_commands(self): + ''' + @return: The list of user commands available for this node. + @rtype: list of str + ''' + prefix = self.ui_command_method_prefix + prefix_len = len(prefix) + return tuple([name[prefix_len:] for name in dir(self) + if name.startswith(prefix) and name != prefix + and inspect.ismethod(getattr(self, name))]) + + def get_group_getter(self, group): + ''' + @param group: A valid configuration group + @type group: str + @return: The getter method for the configuration group. + @rtype: method object + ''' + prefix = self.ui_getgroup_method_prefix + return getattr(self, '%s%s' % (prefix, group)) + + def get_group_setter(self, group): + ''' + @param group: A valid configuration group + @type group: str + @return: The setter method for the configuration group. + @rtype: method object + ''' + prefix = self.ui_setgroup_method_prefix + return getattr(self, '%s%s' % (prefix, group)) + + def get_command_method(self, command): + ''' + @param command: The command to get the method for. + @type command: str + @return: The user command support method. + @rtype: method + @raise ValueError: If the command is not found. + ''' + prefix = self.ui_command_method_prefix + if command in self.list_commands(): + return getattr(self, '%s%s' % (prefix, command)) + else: + self.shell.log.debug('No command named %s in %s (%s)' + % (command, self.name, self.path)) + raise ValueError('No command named "%s".' % command) + + def get_completion_method(self, command): + ''' + @return: A user command's completion method or None. + @rtype: method or None + @param command: The command to get the completion method for. + @type command: str + ''' + prefix = self.ui_complete_method_prefix + try: + method = getattr(self, '%s%s' % (prefix, command)) + except AttributeError: + return None + else: + return method + + def get_command_description(self, command): + ''' + @return: An description string for a user command. + @rtype: str + @param command: The command to describe. + @type command: str + ''' + doc = self.get_command_method(command).__doc__ + if not doc: + doc = "No description available." + return self.shell.con.dedent(doc) + + def get_command_syntax(self, command): + ''' + @return: A list of formatted syntax descriptions for the command: + - (syntax, comments, default_values) + - syntax is the syntax definition line. + - comments is a list of additionnal comments about the syntax. + - default_values is a string with the default parameters values. + @rtype: (str, [str...], str) + @param command: The command to document. + @type command: str + ''' + method = self.get_command_method(command) + parameters, args, kwargs, default = inspect.getargspec(method) + parameters = parameters[1:] + if default is None: + num_defaults = 0 + else: + num_defaults = len(default) + + if num_defaults != 0: + required_parameters = parameters[:-num_defaults] + optional_parameters = parameters[-num_defaults:] + else: + required_parameters = parameters + optional_parameters = [] + + self.shell.log.debug("Required: %s" % str(required_parameters)) + self.shell.log.debug("Optional: %s" % str(optional_parameters)) + + syntax = "B{%s} " % command + + required_parameters_str = '' + for param in required_parameters: + required_parameters_str += "I{%s} " % param + syntax += required_parameters_str + + optional_parameters_str = '' + for param in optional_parameters: + optional_parameters_str += "[I{%s}] " % param + syntax += optional_parameters_str + + comments = [] + #if optional_parameters: + # comments.append(self.shell.con.dedent( + # ''' + # %s - These are optional parameters that can either be + # specified in the above order as positional parameters, or in + # any order at the end of the line as keyword=value parameters. + # ''' % optional_parameters_str[:-1])) + + if args is not None: + syntax += "[I{%s}...] " % args + # comments.append(self.shell.con.dedent( + # ''' + # [I{%s}...] - This command accepts an arbitrary number of + # parameters before any keyword=value parameter. In order to use + # them, you must fill in all previous positional parameters if + # any. See B{DESCRIPTION} below. + # ''' % args)) + + if kwargs is not None: + syntax += "[I{%s=value}...] " % (kwargs) + # comments.append(self.shell.con.dedent( + # ''' + # This command also accepts an arbitrary number of + # keyword=value parameters. See B{DESCRIPTION} below. + # ''')) + + default_values = '' + if num_defaults > 0: + for index, param in enumerate(optional_parameters): + if default[index] is not None: + default_values += "%s=%s " % (param, str(default[index])) + + return syntax, comments, default_values + + def get_command_signature(self, command): + ''' + Get a command's signature. + @param command: The command to get the signature of. + @type command: str + @return: (parameters, free_pparams, free_kparams) where parameters is a + list of all the command's parameters and free_pparams and free_kparams + booleans set to True is the command accepts an arbitrary number of, + respectively, pparams and kparams. + @rtype: ([str...], bool, bool) + ''' + method = self.get_command_method(command) + parameters, args, kwargs, default = inspect.getargspec(method) + parameters = parameters[1:] + if args is not None: + free_pparams = args + else: + free_pparams = False + if kwargs is not None: + free_kparams = kwargs + else: + free_kparams = False + self.shell.log.debug("Signature is %s, %s, %s." + % (str(parameters), + str(free_pparams), + str(free_kparams))) + return parameters, free_pparams, free_kparams + + def get_root(self): + ''' + @return: The root node of the nodes tree. + @rtype: ConfigNode + ''' + if self.is_root(): + return self + else: + return self.parent.get_root() + + def define_config_group_param(self, group, param, type, + description=None, writable=True): + ''' + Helper to define configuration group parameters. + @param group: The configuration group to add the parameter to. + @type group: str + @param param: The new parameter name. + @type param: str + @param description: Optional description string. + @type description: str + @param writable: Whether or not this would be a rw or ro parameter. + @type writable: bool + ''' + if group not in self._configuration_groups: + self._configuration_groups[group] = {} + + if description is None: + description = "The %s %s parameter." % (param, group) + + # Fail early if the type and set/get helpers don't exist + self.get_type_method(type) + self.get_group_getter(group) + if writable: + self.get_group_setter(group) + + self._configuration_groups[group][param] = \ + [type, description, writable] + + def list_config_groups(self): + ''' + Lists the configuration group names. + ''' + return self._configuration_groups.keys() + + def list_group_params(self, group, writable=None): + ''' + Lists the parameters from group matching the optional param, writable + and type supplied (if none is supplied, returns all group parameters. + @param group: The group to list parameters of. + @type group: str + @param writable: Optional writable flag filter. + @type writable: bool + ''' + if group not in self.list_config_groups(): + return [] + else: + params = [] + for p_name, p_def in six.iteritems(self._configuration_groups[group]): + (p_type, p_description, p_writable) = p_def + if writable is not None and p_writable != writable: + continue + params.append(p_name) + + params.sort() + return params + + def get_group_param(self, group, param): + ''' + @param group: The configuration group to retreive the parameter from. + @type group: str + @param param: The parameter name. + @type param: str + @return: A dictionnary for the requested group parameter, with + name, writable, description, group and type fields. + @rtype: dict + @raise ValueError: If the parameter or group does not exist. + ''' + if group not in self.list_config_groups(): + raise ValueError("Not such configuration group %s" % group) + if param not in self.list_group_params(group): + raise ValueError("Not such parameter %s in configuration group %s" + % (param, group)) + (p_type, p_description, p_writable) = \ + self._configuration_groups[group][param] + + return dict(name=param, group=group, type=p_type, + description=p_description, writable=p_writable) + + shell = property(_get_shell, + doc="Gets the shell attached to ConfigNode tree.") + + name = property(_get_name, _set_name, + doc="Gets or sets the node's name.") + + path = property(_get_path, + doc="Gets the node's path.") + + children = property(_list_children, + doc="Lists the node's children.") + + parent = property(_get_parent, + doc="Gets the node's parent.") + + def is_root(self): + ''' + @return: Wether or not we are a root node. + @rtype: bool + ''' + if self._parent is None: + return True + else: + return False + + def get_child(self, name): + ''' + @param name: The child's name. + @type name: str + @return: Our child named by name. + @rtype: ConfigNode + @raise ValueError: If there is no child named by name. + ''' + for child in self._children: + if child.name == name: + return child + else: + raise ValueError("No such path %s/%s" + % (self.path.rstrip('/'), name)) + + def remove_child(self, child): + ''' + Removes a child from our children's list. + @param child: The child to remove. + @type child: ConfigNode + ''' + self._children.remove(child) + + def get_node(self, path): + ''' + Looks up a node by path in the nodes tree. + @param path: The node's path. + @type path: str + @return: The node that has the given path. + @rtype: ConfigNode + @raise ValueError: If there is no node with that path. + ''' + def adjacent_node(name): + ''' + Returns an adjacent node or ourself. + ''' + if name == self._path_current: + return self + elif name == self._path_previous: + if self._parent is not None: + return self._parent + else: + return self + else: + return self.get_child(name) + + + # Cleanup the path + if path is None or path == '': + path = '.' + + # Is it a bookmark ? + if path.startswith('@'): + bookmark = path.lstrip('@').strip() + if bookmark in self.shell.prefs['bookmarks']: + path = self.shell.prefs['bookmarks'][bookmark] + else: + raise ValueError("No such bookmark %s" % bookmark) + + # More cleanup + path = re.sub('%s+' % self._path_separator, self._path_separator, path) + if len(path) > 1: + path = path.rstrip(self._path_separator) + self.shell.log.debug("Looking for path '%s'" % path) + + + # Absolute path - make relative and pass on to root node + if path.startswith(self._path_separator): + next_node = self.get_root() + next_path = path.lstrip(self._path_separator) + if next_path: + return next_node.get_node(next_path) + else: + return next_node + + # Relative path + if self._path_separator in path: + next_node_name, next_path = path.split(self._path_separator, 1) + next_node = adjacent_node(next_node_name) + return next_node.get_node(next_path) + + # Path is just one of our children + return adjacent_node(path) diff --git a/configshell/prefs.py b/configshell/prefs.py new file mode 100644 index 0000000..248cc1d --- /dev/null +++ b/configshell/prefs.py @@ -0,0 +1,149 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +import six + +class Prefs(object): + ''' + This is a preferences backend object used to: + - Hold the ConfigShell preferences + - Handle persistent storage and retrieval of these preferences + - Share the preferences between the ConfigShell and ConfigNode objects + + As it is inherently destined to be shared between objects, this is a Borg. + ''' + _prefs = {} + filename = None + autosave = False + __borg_state = {} + + def __init__(self, filename=None): + ''' + Instanciates the ConfigShell preferences object. + @param filename: File to store the preferencces to. + @type filename: str + ''' + self.__dict__ = self.__borg_state + if filename is not None: + self.filename = filename + + def __getitem__(self, key): + ''' + Proxies dict-like references to prefs. + One specific behavior, though, is that if the key does not exists, + we will return None instead of raising an exception. + @param key: The preferences dictionnary key to get. + @type key: any valid dict key + @return: The key value + @rtype: n/a + ''' + if key in self._prefs: + return self._prefs[key] + else: + return None + + def __setitem__(self, key, value): + ''' + Proxies dict-like references to prefs. + @param key: The preferences dictionnary key to set. + @type key: any valid dict key + ''' + self._prefs[key] = value + if self.autosave: + self.save() + + def __contains__(self, key): + ''' + Do the preferences contain key ? + @param key: The preferences dictionnary key to check. + @type key: any valid dict key + ''' + if key in self._prefs: + return True + else: + return False + + def __delitem__(self, key): + ''' + Deletes a preference key. + @param key: The preference to delete. + @type key: any valid dict key + ''' + del self._prefs[key] + if self.autosave: + self.save() + + def __iter__(self): + ''' + Generic iterator for the preferences. + ''' + return self._prefs.__iter__() + + # Public methods + + def keys(self): + ''' + @return: Returns the list of keys in preferences. + @rtype: list + ''' + return self._prefs.keys() + + def items(self): + ''' + @return: Returns the list of items in preferences. + @rtype: list of (key, value) tuples + ''' + return self._prefs.items() + + def iteritems(self): + ''' + @return: Iterates on the items in preferences. + @rtype: yields items that are (key, value) pairs + ''' + return six.iteritems(self._prefs) + + def save(self, filename=None): + ''' + Saves the preferences to disk. If filename is not specified, + use the default one if it is set, else do nothing. + @param filename: Optional alternate file to use. + @type filename: str + ''' + if filename is None: + filename = self.filename + + if filename is not None: + fsock = open(filename, 'wb') + try: + six.moves.cPickle.dump(self._prefs, fsock, 2) + finally: + fsock.close() + + def load(self, filename=None): + ''' + Loads the preferences from file. Use either the supplied filename, + or the default one if set. Else, do nothing. + ''' + if filename is None: + filename = self.filename + + if filename is not None: + fsock = open(filename, 'rb') + try: + self._prefs = six.moves.cPickle.load(fsock) + finally: + fsock.close() diff --git a/configshell/shell.py b/configshell/shell.py new file mode 100644 index 0000000..32701ec --- /dev/null +++ b/configshell/shell.py @@ -0,0 +1,907 @@ +''' +This file is part of ConfigShell. +Copyright (c) 2011-2013 by Datera, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); you may +not use this file except in compliance with the License. You may obtain +a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +''' + +import os +import six +import sys +from pyparsing import (alphanums, Empty, Group, OneOrMore, Optional, + ParseResults, Regex, Suppress, Word) + +from . import console +from . import log +from . import prefs +from .node import ConfigNode, ExecutionError + +# A fix for frozen packages +import signal +def handle_sigint(signum, frame): + ''' + Raise KeyboardInterrupt when we get a SIGINT. + This is normally done by python, but even after patching + pyinstaller 1.4 to ignore SIGINT in the C wrapper code, we + still have to do the translation ourselves. + ''' + raise KeyboardInterrupt + +try: + signal.signal(signal.SIGINT, handle_sigint) +except Exception: + # In a thread, this fails + pass + +if sys.stdout.isatty(): + import readline + tty=True +else: + tty=False + +# Pyparsing helper to group the location of a token and its value +# http://stackoverflow.com/questions/18706631/pyparsing-get-token-location-in-results-name +locator = Empty().setParseAction(lambda s, l, t: l) +def locatedExpr(expr): + return Group(locator('location') + expr('value')) + +class ConfigShell(object): + ''' + This is a simple CLI command interpreter that can be used both in + interactive or non-interactive modes. + It is based on a tree of ConfigNode objects, which can be navigated. + + The ConfigShell object itself provides global navigation commands. + It also handles the parsing of local commands (specific to a certain + ConfigNode) according to the ConfigNode commands definitions. + If the ConfigNode provides hooks for possible parameter values in a given + context, then the ConfigShell will also provide command-line completion + using the TAB key. If no completion hooks are available from the + ConfigNode, the completion function will still be able to display some help + and general syntax advice (as much as the ConfigNode will provide). + + Interactive sessions can be saved/loaded automatically by ConfigShell is a + writable session directory is supplied. This includes command-line history, + current node and global parameters. + ''' + + default_prefs = {'color_path': 'magenta', + 'color_command': 'cyan', + 'color_parameter': 'magenta', + 'color_keyword': 'cyan', + 'logfile': None, + 'loglevel_console': 'info', + 'loglevel_file': 'debug9', + 'color_mode': True, + 'prompt_length': 30, + 'tree_max_depth': 0, + 'tree_status_mode': True, + 'tree_round_nodes': True, + 'tree_show_root': True + } + + _completion_help_topic = '' + _current_parameter = '' + _current_token = '' + _current_completions = [] + + def __init__(self, preferences_dir=None): + ''' + Creates a new ConfigShell. + @param preferences_dir: Directory to load/save preferences from/to + @type preferences_dir: str + ''' + self._current_node = None + self._root_node = None + self._exit = False + + # Grammar of the command line + command = locatedExpr(Word(alphanums + '_'))('command') + var = Word(alphanums + '_\+/.<>()~@:-%[]') + value = var + keyword = Word(alphanums + '_\-') + kparam = locatedExpr(keyword + Suppress('=') + Optional(value, default=''))('kparams*') + pparam = locatedExpr(var)('pparams*') + parameter = kparam | pparam + parameters = OneOrMore(parameter) + bookmark = Regex('@([A-Za-z0-9:_.]|-)+') + pathstd = Regex('([A-Za-z0-9:_.]|-)*' + '/' + '([A-Za-z0-9:_./]|-)*') \ + | '..' | '.' + path = locatedExpr(bookmark | pathstd | '*')('path') + parser = Optional(path) + Optional(command) + Optional(parameters) + self._parser = parser + + if tty: + readline.set_completer_delims('\t\n ~!#$^&(){}\|;\'",?') + readline.set_completion_display_matches_hook( + self._display_completions) + + self.log = log.Log() + + if preferences_dir is not None: + preferences_dir = os.path.expanduser(preferences_dir) + if not os.path.exists(preferences_dir): + os.makedirs(preferences_dir) + self._prefs_file = preferences_dir + '/prefs.bin' + self.prefs = prefs.Prefs(self._prefs_file) + self._cmd_history = preferences_dir + '/history.txt' + self._save_history = True + if not os.path.isfile(self._cmd_history): + try: + open(self._cmd_history, 'w').close() + except: + self.log.warning("Cannot create history file %s, " + % self._cmd_history + + "command history will not be saved.") + self._save_history = False + + if os.path.isfile(self._cmd_history) and tty: + try: + readline.read_history_file(self._cmd_history) + except IOError: + self.log.warning("Cannot read command history file %s." + % self._cmd_history) + + if self.prefs['logfile'] is None: + self.prefs['logfile'] = preferences_dir + '/' + 'log.txt' + + self.prefs.autosave = True + + else: + self.prefs = prefs.Prefs() + self._save_history = False + + try: + self.prefs.load() + except IOError: + self.log.warning("Could not load preferences file %s." + % self._prefs_file) + + for pref, value in six.iteritems(self.default_prefs): + if pref not in self.prefs: + self.prefs[pref] = value + + self.con = console.Console() + + # Private methods + + def _display_completions(self, substitution, matches, max_length): + ''' + Display the completions. Invoked by readline. + @param substitution: string to complete + @param matches: list of possible matches + @param max_length: length of the longest matching item + ''' + x_orig = self.con.get_cursor_xy()[0] + width = self.con.get_width() + max_length += 2 + + def just(text): + ''' + Justifies the text to the max match length. + ''' + return text.ljust(max_length, " ") + + # Sort and colorize the matches + if self._current_parameter: + keywords = [] + values = [] + for match in matches: + if match.endswith('='): + keywords.append( + self.con.render_text( + just(match), self.prefs['color_keyword'])) + elif '=' in match: + _, _, value = match.partition('=') + values.append( + self.con.render_text( + just(value), self.prefs['color_parameter'])) + else: + values.append( + self.con.render_text( + just(match), self.prefs['color_parameter'])) + matches = values + keywords + else: + paths = [] + commands = [] + for match in matches: + if '/' in match or match.startswith('@') or '*' in match: + paths.append( + self.con.render_text( + just(match), self.prefs['color_path'])) + else: + commands.append( + self.con.render_text( + just(match), self.prefs['color_command'])) + matches = paths + commands + + # Display the possible completions in columns + self.con.raw_write("\n") + if matches: + if max_length < width: + nr_cols = width // max_length + else: + nr_cols = 1 + + for i in six.moves.range(0, len(matches), nr_cols): + self.con.raw_write(''.join(matches[i:i+nr_cols])) + self.con.raw_write('\n') + + # Display the prompt and the command line + line = "%s%s" % (self._get_prompt(), readline.get_line_buffer()) + self.con.raw_write("%s" % line) + + # Move the cursor where it should be + y_pos = self.con.get_cursor_xy()[1] + self.con.set_cursor_xy(x_orig, y_pos) + + def _complete_token_command(self, text, path, command): + ''' + Completes a partial command token, which could also be the beginning + of a path. + @param path: Path of the target ConfigNode. + @type path: str + @param command: The command (if any) found by the parser. + @type command: str + @param text: Current text being typed by the user. + @type text: str + @return: Possible completions for the token. + @rtype: list of str + ''' + completions = [] + target = self._current_node.get_node(path) + commands = target.list_commands() + self.log.debug("Completing command token among %s" % str(commands)) + + # Start with the possible commands + for command in commands: + if command.startswith(text): + completions.append(command) + if len(completions) == 1: + completions[0] = completions[0] + ' ' + + # No identified path yet on the command line, this might be it + if not path: + path_completions = [child.name + '/' + for child in self._current_node.children + if child.name.startswith(text)] + if not text: + path_completions.append('/') + if len(self._current_node.children) > 1: + path_completions.append('* ') + + if path_completions: + if completions: + self._current_token = \ + self.con.render_text( + 'path', self.prefs['color_path']) \ + + '|' \ + + self.con.render_text( + 'command', self.prefs['color_command']) + else: + self._current_token = \ + self.con.render_text( + 'path', self.prefs['color_path']) + else: + self._current_token = \ + self.con.render_text( + 'command', self.prefs['color_command']) + if len(path_completions) == 1 and \ + not path_completions[0][-1] in [' ', '*'] and \ + not self._current_node.get_node(path_completions[0]).children: + path_completions[0] = path_completions[0] + ' ' + completions.extend(path_completions) + else: + self._current_token = \ + self.con.render_text( + 'command', self.prefs['color_command']) + + # Even a bookmark + bookmarks = ['@' + bookmark for bookmark in self.prefs['bookmarks'] + if bookmark.startswith("%s" % text.lstrip('@'))] + self.log.debug("Found bookmarks %s." % str(bookmarks)) + if bookmarks: + completions.extend(bookmarks) + + + # We are done + return completions + + def _complete_token_path(self, text): + ''' + Completes a partial path token. + @param text: Current text being typed by the user. + @type text: str + @return: Possible completions for the token. + @rtype: list of str + ''' + completions = [] + if text.endswith('.'): + text = text + '/' + (basedir, slash, partial_name) = text.rpartition('/') + self.log.debug("Got basedir=%s, partial_name=%s" + % (basedir, partial_name)) + basedir = basedir + slash + target = self._current_node.get_node(basedir) + names = [child.name for child in target.children] + + # Iterall path completion + if names and partial_name in ['', '*']: + # Not suggesting iterall to end a path that has only one + # child allows for fast TAB action to add the only child's + # name. + if len(names) > 1: + completions.append("%s* " % basedir) + + for name in names: + num_matches = 0 + if name.startswith(partial_name): + num_matches += 1 + if num_matches == 1: + completions.append("%s%s/" % (basedir, name)) + else: + completions.append("%s%s" % (basedir, name)) + + # Bookmarks + bookmarks = ['@' + bookmark for bookmark in self.prefs['bookmarks'] + if bookmark.startswith("%s" % text.lstrip('@'))] + self.log.debug("Found bookmarks %s." % str(bookmarks)) + if bookmarks: + completions.extend(bookmarks) + + if len(completions) == 1: + self.log.debug("One completion left.") + if not completions[0].endswith("* "): + if not self._current_node.get_node(completions[0]).children: + completions[0] = completions[0].rstrip('/') + ' ' + + self._current_token = \ + self.con.render_text( + 'path', self.prefs['color_path']) + return completions + + def _complete_token_pparam(self, text, path, command, pparams, kparams): + ''' + Completes a positional parameter token, which can also be the keywork + part of a kparam token, as before the '=' sign is on the line, the + parser cannot know better. + @param path: Path of the target ConfigNode. + @type path: str + @param command: The command (if any) found by the parser. + @type command: str + @param pparams: Positional parameters from commandline. + @type pparams: list of str + @param kparams: Keyword parameters from commandline. + @type kparams: dict of str:str + @param text: Current text being typed by the user. + @type text: str + @return: Possible completions for the token. + @rtype: list of str + ''' + completions = [] + target = self._current_node.get_node(path) + cmd_params, free_pparams, free_kparams = \ + target.get_command_signature(command) + current_parameters = {} + for index in range(len(pparams)): + if index < len(cmd_params): + current_parameters[cmd_params[index]] = pparams[index] + for key, value in six.iteritems(kparams): + current_parameters[key] = value + self._completion_help_topic = command + completion_method = target.get_completion_method(command) + self.log.debug("Command %s accepts parameters %s." + % (command, cmd_params)) + + # Do we still accept positional params ? + pparam_ok = True + for index in range(len(cmd_params)): + param = cmd_params[index] + if param in kparams: + if index <= len(pparams): + pparam_ok = False + self.log.debug( + "No more possible pparams (because of kparams).") + break + elif (text.strip() == '' and len(pparams) == len(cmd_params)) \ + or (len(pparams) > len(cmd_params)): + pparam_ok = False + self.log.debug("No more possible pparams.") + break + else: + if len(cmd_params) == 0: + pparam_ok = False + self.log.debug("No more possible pparams (none exists)") + + # If we do, find out which one we are completing + if pparam_ok: + if not text: + pparam_index = len(pparams) + else: + pparam_index = len(pparams) - 1 + self._current_parameter = cmd_params[pparam_index] + self.log.debug("Completing pparam %s." % self._current_parameter) + if completion_method: + pparam_completions = completion_method( + current_parameters, text, self._current_parameter) + if pparam_completions is not None: + completions.extend(pparam_completions) + + # Add the keywords for parameters not already on the line + if text: + offset = 1 + else: + offset = 0 + keyword_completions = [param + '=' \ + for param in cmd_params[len(pparams)-offset:] \ + if param not in kparams \ + if param.startswith(text)] + + self.log.debug("Possible pparam values are %s." + % str(completions)) + self.log.debug("Possible kparam keywords are %s." + % str(keyword_completions)) + + if keyword_completions: + if self._current_parameter: + self._current_token = \ + self.con.render_text( + self._current_parameter, \ + self.prefs['color_parameter']) \ + + '|' \ + + self.con.render_text( + 'keyword=', self.prefs['color_keyword']) + else: + self._current_token = \ + self.con.render_text( + 'keyword=', self.prefs['color_keyword']) + else: + if self._current_parameter: + self._current_token = \ + self.con.render_text( + self._current_parameter, + self.prefs['color_parameter']) + else: + self._current_token = '' + + completions.extend(keyword_completions) + + if free_kparams or free_pparams: + self.log.debug("Command has free [kp]params.") + if completion_method: + self.log.debug("Calling completion method for free params.") + free_completions = completion_method( + current_parameters, text, '*') + do_free_pparams = False + do_free_kparams = False + for free_completion in free_completions: + if free_completion.endswith("="): + do_free_kparams = True + else: + do_free_pparams = True + + if do_free_pparams: + self._current_token = \ + self.con.render_text( + free_pparams, self.prefs['color_parameter']) \ + + '|' + self._current_token + self._current_token = self._current_token.rstrip('|') + if not self._current_parameter: + self._current_parameter = 'free_parameter' + + if do_free_kparams: + if not 'keyword=' in self._current_token: + self._current_token = \ + self.con.render_text( + 'keyword=', self.prefs['color_keyword']) \ + + '|' + self._current_token + self._current_token = self._current_token.rstrip('|') + if not self._current_parameter: + self._current_parameter = 'free_parameter' + + completions.extend(free_completions) + + self.log.debug("Found completions %s." % str(completions)) + return completions + + def _complete_token_kparam(self, text, path, command, pparams, kparams): + ''' + Completes a keyword=value parameter token. + @param path: Path of the target ConfigNode. + @type path: str + @param command: The command (if any) found by the parser. + @type command: str + @param pparams: Positional parameters from commandline. + @type pparams: list of str + @param kparams: Keyword parameters from commandline. + @type kparams: dict of str:str + @param text: Current text being typed by the user. + @type text: str + @return: Possible completions for the token. + @rtype: list of str + ''' + self.log.debug("Called for text='%s'" % text) + target = self._current_node.get_node(path) + cmd_params = target.get_command_signature(command)[0] + self.log.debug("Command %s accepts parameters %s." + % (command, cmd_params)) + + (keyword, sep, current_value) = text.partition('=') + self.log.debug("Completing '%s' for kparam %s" + % (current_value, keyword)) + + self._current_parameter = keyword + current_parameters = {} + for index in range(len(pparams)): + current_parameters[cmd_params[index]] = pparams[index] + for key, value in six.iteritems(kparams): + current_parameters[key] = value + completion_method = target.get_completion_method(command) + if completion_method: + completions = completion_method( + current_parameters, current_value, keyword) + if completions is None: + completions = [] + + self._current_token = \ + self.con.render_text( + self._current_parameter, self.prefs['color_parameter']) + + self.log.debug("Found completions %s." % str(completions)) + + return ["%s=%s" % (keyword, completion) for completion in completions] + + def _complete(self, text, state): + ''' + Text completion method, directly called by readline. + Finds out what token the user wants completion for, and calls the + _dispatch_completion() to get the possible completions. + Then implements the state system needed by readline to return those + possible completions to readline. + @param text: The text to complete. + @type text: str + @returns: The next possible completion for text. + @rtype: str + ''' + if state == 0: + cmdline = readline.get_line_buffer() + self._current_completions = [] + self._completion_help_topic = '' + self._current_parameter = '' + + (parse_results, path, command, pparams, kparams) = \ + self._parse_cmdline(cmdline) + + beg = readline.get_begidx() + end = readline.get_endidx() + current_token = None + if beg == end: + # No text under the cursor, fake it so that the parser + # result_trees gives us a token name on a second parser call + self.log.debug("Faking text entry on commandline.") + parse_results = self._parse_cmdline(cmdline + 'x')[0] + + if parse_results.command.value == 'x': + current_token = 'command' + elif 'x' in [x.value for x in parse_results.pparams]: + current_token = 'pparam' + elif 'x' in [x.value for x in parse_results.kparams]: + current_token = 'kparam' + elif path and beg == parse_results.path.location: + current_token = 'path' + elif command and beg == parse_results.command.location: + current_token = 'command' + elif pparams and beg in [p.location for p in parse_results.pparams]: + current_token = 'pparam' + elif kparams and beg in [k.location for k in parse_results.kparams]: + current_token = 'kparam' + + self._current_completions = \ + self._dispatch_completion(path, command, + pparams, kparams, + text, current_token) + + self.log.debug("Returning completions %s to readline." + % str(self._current_completions)) + + if state < len(self._current_completions): + return self._current_completions[state] + else: + return None + + def _dispatch_completion(self, path, command, + pparams, kparams, text, current_token): + ''' + This method takes care of dispatching the current completion request + from readline (via the _complete() method) to the relevant token + completion methods. It has to cope with the fact that the commandline + being incomplete yet, + Of course, as the command line is still unfinished, the parser can + only do so much of a job. For instance, until the '=' sign is on the + command line, there is no way to distinguish a positional parameter + from the begining of a keyword=value parameter. + @param path: Path of the target ConfigNode. + @type path: str + @param command: The command (if any) found by the parser. + @type command: str + @param pparams: Positional parameters from commandline. + @type pparams: list of str + @param kparams: Keyword parameters from commandline. + @type kparams: dict of str:str + @param text: Current text being typed by the user. + @type text: str + @param current_token: Name of token to complete. + @type current_token: str + @return: Possible completions for the token. + @rtype: list of str + ''' + completions = [] + + self.log.debug("Dispatching completion for %s token. " + % current_token + + "text='%s', path='%s', command='%s', " + % (text, path, command) + + "pparams=%s, kparams=%s" + % (str(pparams), str(kparams))) + + (path, iterall) = path.partition('*')[:2] + if iterall: + try: + target = self._current_node.get_node(path) + except ValueError: + cpl_path = path + else: + children = target.children + if children: + cpl_path = children[0].path + else: + cpl_path = path + + + if current_token == 'command': + completions = self._complete_token_command(text, cpl_path, command) + elif current_token == 'path': + completions = self._complete_token_path(text) + elif current_token == 'pparam': + completions = \ + self._complete_token_pparam(text, cpl_path, command, + pparams, kparams) + elif current_token == 'kparam': + completions = \ + self._complete_token_kparam(text, cpl_path, command, + pparams, kparams) + else: + self.log.debug("Cannot complete unknown token %s." + % current_token) + + return completions + + def _get_prompt(self): + ''' + Returns the command prompt string. + ''' + prompt_path = self._current_node.path + prompt_length = self.prefs['prompt_length'] + + if prompt_length and prompt_length < len(prompt_path): + half = (prompt_length - 3) // 2 + prompt_path = "%s...%s" \ + % (prompt_path[:half], prompt_path[-half:]) + + if 'prompt_msg' in dir(self._current_node): + return "%s%s> " % (self._current_node.prompt_msg(), + prompt_path) + else: + return "%s> " % prompt_path + + def _cli_loop(self): + ''' + Starts the configuration shell interactive loop, that: + - Goes to the last current path + - Displays the prompt + - Waits for user input + - Runs user command + ''' + while not self._exit: + try: + readline.parse_and_bind("tab: complete") + readline.set_completer(self._complete) + cmdline = six.moves.input(self._get_prompt()).strip() + except EOFError: + self.con.raw_write('exit\n') + cmdline = "exit" + self.run_cmdline(cmdline) + if self._save_history: + try: + readline.write_history_file(self._cmd_history) + except IOError: + self.log.warning( + "Cannot write to command history file %s." \ + % self._cmd_history) + self.log.warning( + "Saving command history has been disabled!") + self._save_history = False + + def _parse_cmdline(self, line): + ''' + Parses the command line entered by the user. This is a wrapper around + the actual pyparsing parser that pre-chews the result trees to + cleanly extract the tokens we care for (parameters, path, command). + @param line: The command line to parse. + @type line: str + @return: (result_trees, path, command, pparams, kparams), + pparams being positional parameters and kparams the keyword=value. + @rtype: (pyparsing.ParseResults, str, str, list, dict) + ''' + self.log.debug("Parsing commandline.") + path = '' + command = '' + pparams = [] + kparams = {} + + parse_results = self._parser.parseString(line) + if isinstance(parse_results.path, ParseResults): + path = parse_results.path.value + if isinstance(parse_results.command, ParseResults): + command = parse_results.command.value + if isinstance(parse_results.pparams, ParseResults): + pparams = [pparam.value for pparam in parse_results.pparams] + if isinstance(parse_results.kparams, ParseResults): + kparams = dict([kparam.value for kparam in parse_results.kparams]) + + self.log.debug("Parse gave path='%s' command='%s' " % (path, command) + + "pparams=%s " % str(pparams) + + "kparams=%s" % str(kparams)) + return (parse_results, path, command, pparams, kparams) + + def _execute_command(self, path, command, pparams, kparams): + ''' + Calls the target node to execute a command. + Behavior depends on the target node command's result: + - An 'EXIT' string will trigger shell exit. + - None will do nothing. + - A ConfigNode object will trigger a current_node change. + @param path: Path of the target node. + @type path: str + @param command: The command to call. + @type command: str + @param pparams: The positional parameters to use. + @type pparams: list + @param kparams: The keyword=value parameters to use. + @type kparams: dict + ''' + if path.endswith('*'): + path = path.rstrip('*') + iterall = True + else: + iterall = False + + if not path: + path = '.' + + if not command: + if iterall: + command = 'ls' + else: + command = 'cd' + pparams = ['.'] + + try: + target = self._current_node.get_node(path) + except ValueError as msg: + raise ExecutionError(str(msg)) + + result = None + if not iterall: + targets = [target] + else: + targets = target.children + for target in targets: + if iterall: + self.con.display("[%s]" % target.path) + result = target.execute_command(command, pparams, kparams) + self.log.debug("Command execution returned %r" % result) + if isinstance(result, ConfigNode): + self._current_node = result + elif result == 'EXIT': + self._exit = True + elif result is not None: + raise ExecutionError("Unexpected result: %r" % result) + + # Public methods + + def run_cmdline(self, cmdline): + ''' + Runs the specified command. Global commands are checked first, + then local commands from the current node. + + Command syntax is: + [PATH] COMMAND [POSITIONAL_PARAMETER]+ [PARAMETER=VALUE]+ + + @param cmdline: The command line to run + @type cmdline: str + ''' + if cmdline: + self.log.debug("Running command line '%s'." % cmdline) + path, command, pparams, kparams = self._parse_cmdline(cmdline)[1:] + self._execute_command(path, command, pparams, kparams) + + def run_script(self, script_path, exit_on_error=True): + ''' + Runs the script located at script_path. + Script runs always start from the root context. + @param script_path: File path of the script to run + @type script_path: str + @param exit_on_error: If True, stops the run if an error occurs + @type exit_on_error: bool + ''' + try: + script_fd = open(script_path, 'r') + self.run_stdin(script_fd, exit_on_error) + except IOError as msg: + raise IOError(msg) + finally: + script_fd.close() + + def run_stdin(self, file_descriptor=sys.stdin, exit_on_error=True): + ''' + Reads commands to be run from a file descriptor, stdin by default. + The run always starts from the root context. + @param file_descriptor: The file descriptor to read commands from + @type file_descriptor: file object + @param exit_on_error: If True, stops the run if an error occurs + @type exit_on_error: bool + ''' + self._current_node = self._root_node + for cmdline in file_descriptor: + try: + self.run_cmdline(cmdline) + except Exception as msg: + self.log.error(msg) + if exit_on_error is True: + raise ExecutionError("Aborting run on error.") + + self.log.exception("Keep running after an error.") + + def run_interactive(self): + ''' + Starts interactive CLI mode. + ''' + history = self.prefs['path_history'] + index = self.prefs['path_history_index'] + if history and index: + if index < len(history): + try: + target = self._root_node.get_node(history[index]) + except ValueError: + self._current_node = self._root_node + else: + self._current_node = target + + while True: + try: + old_completer = readline.get_completer() + self._cli_loop() + break + except KeyboardInterrupt: + self.con.raw_write('\n') + finally: + readline.set_completer(old_completer) + + def attach_root_node(self, root_node): + ''' + @param root_node: The root ConfigNode object + @type root_node: ConfigNode + ''' + self._current_node = root_node + self._root_node = root_node diff --git a/configshell_fb b/configshell_fb new file mode 120000 index 0000000..76ca383 --- /dev/null +++ b/configshell_fb @@ -0,0 +1 @@ +configshell
\ No newline at end of file diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..fbfb22c --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +python-configshell-fb (1.1.20-1) unstable; urgency=medium + + [ Christophe Vu-Brugier ] + * Initial release. (Closes: #838861) + + -- Christophe Vu-Brugier <[email protected]> Thu, 22 Sep 2016 22:29:14 +0200 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..8212b70 --- /dev/null +++ b/debian/control @@ -0,0 +1,82 @@ +Source: python-configshell-fb +Section: python +Priority: optional +Maintainer: Debian LIO Target Packagers <[email protected]> +Uploaders: Christophe Vu-Brugier <[email protected]>, + Ritesh Raj Sarraf <[email protected]>, + Christian Seiler <[email protected]> +Build-Depends: debhelper (>= 9), + dh-python, + python-all, + python-epydoc, + python-pyparsing, + python-setuptools, + python-six, + python3-all, + python3-pyparsing, + python3-setuptools, + python3-six +Standards-Version: 3.9.8 +Homepage: https://github.com/open-iscsi/configshell-fb +Vcs-Git: https://anonscm.debian.org/git/linux-target/python-configshell-fb.git -b debian/master +Vcs-Browser: https://anonscm.debian.org/cgit/linux-target/python-configshell-fb.git + +Package: python-configshell-fb +Architecture: all +Depends: ${misc:Depends}, + ${python:Depends}, + python-pyparsing, + python-six, + python-urwid, +Suggests: python-configshell-fb-doc +Conflicts: python-configshell +Description: Python library for building configuration shells - Python 2 + The configshell-fb package is a Python library that provides a + framework for building simple but nice CLI-based applications. + . + The configshell-fb package is a fork of the "configshell" code + written by RisingTide Systems. The "-fb" differentiates between the + original and this version. Please ensure to use either all "fb" + versions of the targetcli components -- targetcli, rtslib, and + configshell, or stick with all non-fb versions, since they are no + longer strictly compatible. + . + This package contains the Python 2 module. + +Package: python3-configshell-fb +Architecture: all +Depends: ${misc:Depends}, + ${python3:Depends}, + python3-pyparsing, + python3-six, + python3-urwid, +Suggests: python-configshell-fb-doc +Description: Python library for building configuration shells - Python 3 + The configshell-fb package is a Python library that provides a + framework for building simple but nice CLI-based applications. + . + The configshell-fb package is a fork of the "configshell" code + written by RisingTide Systems. The "-fb" differentiates between the + original and this version. Please ensure to use either all "fb" + versions of the targetcli components -- targetcli, rtslib, and + configshell, or stick with all non-fb versions, since they are no + longer strictly compatible. + . + This package contains the Python 3 module. + +Package: python-configshell-fb-doc +Section: doc +Architecture: all +Depends: ${misc:Depends} +Description: Python library for building configuration shells - doc + The configshell-fb package is a Python library that provides a + framework for building simple but nice CLI-based applications. + . + The configshell-fb package is a fork of the "configshell" code + written by RisingTide Systems. The "-fb" differentiates between the + original and this version. Please ensure to use either all "fb" + versions of the targetcli components -- targetcli, rtslib, and + configshell, or stick with all non-fb versions, since they are no + longer strictly compatible. + . + This package contains the documentation. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..3ed6374 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,38 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: configshell-fb +Source: https://github.com/open-iscsi/configshell-fb + +Files: * +Copyright: 2010-2011 RisingTide Systems, LLC. + 2010-2013 Jerome Martin <[email protected]> + 2011-2013 Datera, Inc. + 2011-2016 Andy Grover <[email protected]> + 2013-2016 Christophe Vu-Brugier <[email protected]> +License: Apache-2.0 + +Files: debian/* +Comment: The original Debianization took place upstream. +Copyright: 2010-2011 RisingTide Systems, LLC. + 2010-2013 Jerome Martin <[email protected]> + 2011-2013 Datera, Inc. + 2011-2016 Andy Grover <[email protected]> + 2013-2016 Christophe Vu-Brugier <[email protected]> + 2016 Ritesh Raj Sarraf <[email protected]> + 2016 Christian Seiler <[email protected]> +License: Apache-2.0 + +License: Apache-2.0 + Licensed under the Apache License, Version 2.0 (the "License"); you + may not use this file except in compliance with the License. You may + obtain a copy of the License at + . + https://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + On Debian systems, the complete text of the Apache License 2.0 can + be found in "/usr/share/common-licenses/Apache-2.0" diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000..b41ee25 --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,12 @@ +[DEFAULT] +pristine-tar = True +color = auto +upstream-branch = upstream/master +debian-branch = debian/master + +[import-orig] +dch = True + +[dch] +meta = True +multimaint-merge = True diff --git a/debian/python-configshell-fb-doc.doc-base b/debian/python-configshell-fb-doc.doc-base new file mode 100644 index 0000000..f821c86 --- /dev/null +++ b/debian/python-configshell-fb-doc.doc-base @@ -0,0 +1,9 @@ +Document: python-configshell-fb +Title: python-configshell documentation +Author: Jerome Martin <[email protected]> +Abstract: configshell is a library which is used to create cli interfaces +Section: Programming/Python + +Format: HTML +Index: /usr/share/doc/python-configshell-fb-doc/html/index.html +Files: /usr/share/doc/python-configshell-fb-doc/html/*.html diff --git a/debian/python-configshell-fb.install b/debian/python-configshell-fb.install new file mode 100644 index 0000000..b2cc136 --- /dev/null +++ b/debian/python-configshell-fb.install @@ -0,0 +1 @@ +usr/lib/python2* diff --git a/debian/python3-configshell-fb.install b/debian/python3-configshell-fb.install new file mode 100644 index 0000000..4606faa --- /dev/null +++ b/ |
