diff --git a/.coveragerc b/.coveragerc index 4bb679a..8d44872 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,9 @@ [run] omit = docs/* - scripttease/cli/__init__.py + scripttease/cli/* sandbox setup.py + tests/* tmp/* tmp.* diff --git a/README.markdown b/README.markdown index 74a067e..c251b53 100644 --- a/README.markdown +++ b/README.markdown @@ -2,6 +2,6 @@ ![](https://img.shields.io/badge/status-active-green.svg) ![](https://img.shields.io/badge/stage-development-blue.svg) -![](https://img.shields.io/badge/coverage-55%25-yellow.svg) +![](https://img.shields.io/badge/coverage-100%25-green.svg) A collection of classes and commands for automated command line scripting using Python. \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt index a8b6892..f706c76 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.8.18-d \ No newline at end of file +6.0.0-d \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..23ddba2 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,229 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) + $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + #./generate_command_signatures.py > source/_command-examples.rst + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/BasisHR.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/BasisHR.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/BasisHR" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/BasisHR" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: robots +robots: + cp source/robots.txt build/html/; + diff --git a/docs/generate_command_signatures.py b/docs/generate_command_signatures.py new file mode 100755 index 0000000..28fbc20 --- /dev/null +++ b/docs/generate_command_signatures.py @@ -0,0 +1,65 @@ +#! /usr/bin/env python + +from collections import OrderedDict +import inspect +import sys + +sys.path.append("../") + +from script_tease.mappings import MAPPING + + +# https://stackoverflow.com/a/52003056/241720 +def get_signature(fn): + params = inspect.signature(fn).parameters + args = [] + kwargs = OrderedDict() + for p in params.values(): + if p.default is p.empty: + args.append(p.name) + else: + kwargs[p.name] = p.default + return args, kwargs + + +keys = list(MAPPING.keys()) +keys.sort() + +for key in keys: + cls = MAPPING[key] + + print(key) + print("." * len(key)) + print("") + + extra = cls.get_docs() + if extra is not None: + print(extra) + print("") + + # if cls.__init__.__doc__: + # print(cls.__init__.__doc__) + # print("") + + print(".. code-block:: cfg") + print("") + + if cls.__doc__: + print(" [%s]" % cls.__doc__.strip().replace(".", "").lower()) + else: + print(" [run a %s command]" % cls.__name__.lower()) + + args, kwargs = get_signature(cls.__init__) + + line = list() + for a in args: + if a not in ("self", "kwargs"): + line.append(a) + + print(" %s = %s" % (key, " ".join(line))) + + for option, value in kwargs.items(): + print(" %s = %s" % (option, value)) + + print("") + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..594f601 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,272 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source +set I18NSPHINXOPTS=%SPHINXOPTS% source +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\BasisHR.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\BasisHR.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/requirements.pip b/docs/requirements.pip new file mode 100644 index 0000000..586f866 --- /dev/null +++ b/docs/requirements.pip @@ -0,0 +1,3 @@ +sphinx +sphinx_rtd_theme +git+https://github.com/develmaycare/sphinx-helpers \ No newline at end of file diff --git a/docs/source/_command-examples.rst b/docs/source/_command-examples.rst new file mode 100644 index 0000000..fdd63ad --- /dev/null +++ b/docs/source/_command-examples.rst @@ -0,0 +1,440 @@ +apache.disable_module +..................... + +.. code-block:: ini + + [disable an apache module] + apache.disable_module = module_name + +apache.disable_site +................... + +.. code-block:: ini + + [disable a virtual host] + apache.disable_site = domain_name + +apache.enable_module +.................... + +.. code-block:: ini + + [enable an apache module] + apache.enable_module = module_name + +apache.enable_site +.................. + +.. code-block:: ini + + [enable a virtual host] + apache.enable_site = domain_name + +apache.test +........... + +.. code-block:: ini + + [run an apache config test] + apache: test + +append +...... + +.. code-block:: ini + + [append to a file] + append = path + content = None + +archive +....... + +.. code-block:: ini + + [create an archive file] + archive = from_path + absolute = False + exclude = None + file_name = archive.tgz + strip = 0 + to_path = . + view = False + +certbot +....... + +Alias: ssl + +.. code-block:: ini + + [get new ssl certificate from let's encrypt] + certbot = domain_name + email = None + webroot = None + +copy +.... + +.. code-block:: ini + + [copy a file or directory] + copy = from_path to_path + overwrite = False + recursive = False + +django +...... + +.. code-block:: ini + + [run a django management command] + django = name + +django.dumpdata +............... + +.. code-block:: ini + + [export django fixtures] + django.dumpdata = app_name + file_name = initial + indent = 4 + natural_foreign = False + natural_primary = False + path = None + +django.loaddata +............... + +.. code-block:: ini + + [load django fixtures] + django.loaddata = app_name + file_name = initial + path = None + +extract +....... + +.. code-block:: ini + + [extract an archive] + extract = from_path + absolute = False + exclude = None + file_name = archive.tgz + strip = 0 + to_path = None + view = False + +install +....... + +.. code-block:: ini + + [install a package using apt-get] + apt = package + remove = False + +makedir +....... + +.. code-block:: ini + + [create a directory] + makedir = path + mode = None + recursive = True + +message +....... + +.. code-block:: ini + + [run a message command] + message = output + back_title = Message + dialog = False + height = 15 + width = 100 + +mkdir +..... + +.. code-block:: ini + + [create a directory] + mkdir = path + mode = None + recursive = True + +move +.... + +.. code-block:: ini + + [move a file or directory] + move = from_path to_path + +perms +..... + +.. code-block:: ini + + [set permissions on a file or directory] + perms = path + group = None + mode = None + owner = None + recursive = False + +pg.createdb +........... + +.. code-block:: ini + + [create a postgresql database] + pg.createdb = name + admin_pass = None + admin_user = postgres + host = localhost + owner = None + port = 5432 + template = None + +pg.createuser +............. + +.. code-block:: ini + + [create a postgresql user] + pg.createuser = name + admin_pass = None + admin_user = postgres + host = localhost + password = None + port = 5432 + +pg.db +..... + +.. code-block:: ini + + [create a postgresql database] + pg.db = name + admin_pass = None + admin_user = postgres + host = localhost + owner = None + port = 5432 + template = None + +pg.dropdb +......... + +.. code-block:: ini + + [remove a postgresql database] + pg.dropdb = name + admin_pass = None + admin_user = postgres + host = localhost + port = 5432 + +pg.dropuser +........... + +.. code-block:: ini + + [remove a postgres user] + pg.dropuser = name + admin_pass = None + admin_user = postgres + host = localhost + port = 5432 + +pg.dump +....... + +.. code-block:: ini + + [export a postgres database] + pg.dump = name + admin_pass = None + admin_user = postgres + file_name = None + host = localhost + port = 5432 + +pg.exists +......... + +.. code-block:: ini + + [determine if a postgres database exists] + pg.exists = name + admin_pass = None + admin_user = postgres + host = localhost + port = 5432 + +pip +... + +.. code-block:: ini + + [install a python package using pip] + pip = package + remove = False + upgrade = False + +psql +.... + +.. code-block:: ini + + [execute a psql command] + psql = sql + database = template1 + host = localhost + password = None + port = 5432 + user = postgres + +reload +...... + +.. code-block:: ini + + [reload a service] + reload = service + +remove +...... + +.. code-block:: ini + + [remove a file or directory] + remove = path + force = False + recursive = False + +restart +....... + +.. code-block:: ini + + [restart a service] + restart = service + +rsync +..... + +.. code-block:: ini + + [synchronize files from a local to remote directory] + rsync = source target + delete = False + guess = False + host = None + key_file = None + links = True + port = 22 + recursive = True + user = None + +run +... + +.. code-block:: ini + + [a command to be executed] + run = statement + comment = None + condition = None + cd = None + environments = None + function = None + prefix = None + register = None + shell = None + stop = False + sudo = None + tags = None + +scopy +..... + +.. code-block:: ini + + [copy a file from the local (machine) to the remote host] + scp = from_path to_path + host = None + key_file = None + port = 22 + user = None + +sed +... + +.. code-block:: ini + + [replace text in a file] + sed = path + backup = .b + change = None + delimiter = / + find = None + +start +..... + +.. code-block:: ini + + [start a service] + start = service + +stop +.... + +.. code-block:: ini + + [stop a service] + stop = service + +symlink +....... + +.. code-block:: ini + + [create a symlink] + symlink = source + force = False + target = None + +touch +..... + +.. code-block:: ini + + [touch a file or directory] + touch = path + +virtualenv +.......... + +.. code-block:: ini + + [create a python virtual environment] + virtualenv = name + +write +..... + +.. code-block:: ini + + [write to a file] + write = path + content = None + overwrite = False diff --git a/docs/source/_data/cloc.csv b/docs/source/_data/cloc.csv new file mode 100644 index 0000000..8440089 --- /dev/null +++ b/docs/source/_data/cloc.csv @@ -0,0 +1,3 @@ +files,language,blank,comment,code +17,Python,705,569,1156 +17,SUM,705,569,1156 diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css new file mode 100644 index 0000000..7c1a520 --- /dev/null +++ b/docs/source/_static/css/custom.css @@ -0,0 +1,10 @@ +/* override table width restrictions */ +.wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal; +} + +.wy-table-responsive { + margin-bottom: 24px; + max-width: 100%; + overflow: visible; +} diff --git a/docs/source/_static/images/slack-1.jpg b/docs/source/_static/images/slack-1.jpg new file mode 100644 index 0000000..192b2a3 Binary files /dev/null and b/docs/source/_static/images/slack-1.jpg differ diff --git a/docs/source/_static/images/slack-2.jpg b/docs/source/_static/images/slack-2.jpg new file mode 100644 index 0000000..69c7db6 Binary files /dev/null and b/docs/source/_static/images/slack-2.jpg differ diff --git a/docs/source/_static/images/slack-3.jpg b/docs/source/_static/images/slack-3.jpg new file mode 100644 index 0000000..a6778a8 Binary files /dev/null and b/docs/source/_static/images/slack-3.jpg differ diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..a02b7de --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# +# Script Tease documentation build configuration file, created by +# sphinx-quickstart on Mon Apr 11 17:36:02 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. +import os +from sphinx_helpers import get_release, get_version +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, os.path.abspath("../../")) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Script Tease (Python)' +copyright = u'Pleasant Tents, LLC' +author = u'Shawn Davis' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = get_version("../../VERSION.txt") + +# The full version, including alpha/beta/rc tags. +release = get_release("../../VERSION.txt") + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +#html_title = u'Ninjas v%s' % version + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + + +def setup(app): + # Some csv-table tables in the RTD theme were scrolling horizontally. + # See https://github.com/snide/sphinx_rtd_theme/issues/117 + # Apparently the placement of this function is significant. It wasn't + # working at the end of the file. + # See https://github.com/syncany/syncany-docs/blob/82166518720051e413231d621c3b23b9ac35cbf6/source/conf.py#L135 + # app.add_javascript("js/custom.js") + app.add_stylesheet("css/custom.css") + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +#html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'ScriptTeaseDoc' + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'script_tease', u'Script Tease Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} + +# Read the Docs Theme +import sphinx_rtd_theme +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst new file mode 100644 index 0000000..15c8acd --- /dev/null +++ b/docs/source/configuration.rst @@ -0,0 +1,166 @@ +.. _configuration: + +************* +Configuration +************* + +Generating Commands From a File +=============================== + +The :py:class:`scripttease.parsers.ini.Config` class may instantiate commands by loading a configuration file. + +.. note:: + Additional formats such as JSON or YAML may be supported in the future. + +An example file: + +.. code-block:: ini + + [install apache] + install: apache2 + + [create the web site directory] + mkdir: /var/www/domains/example_com/www + recursive: yes + + [set permissions on the website directory] + perms: /var/www/domains/example_com/www + group: www-data + mode: 775 + owner: www-data + +Notes regarding this format: + +- This is the standard format for Python's ConfigParser. If you prefer, you may use ``=`` instead of ``:``. +- The first line is the INI section and is used as the default comment. +- The command name must be the *first* option in the section. +- The arguments for the command appear as the value of the first option in the section. Arguments are separated by a + space. +- Arguments that should be treated as a single value should be enclosed in double quotes. +- ``yes`` and ``no`` are interpreted as boolean values. +- List values, where required, are separated by commas. + +Common Parameters +----------------- + +All commands support the following common parameters: + +- ``comment``: A comment regarding the command. +- ``condition``: A condition for execution. For example, ``! -f /path/to/some/file.txt`` +- ``cd``: The path from which a command should be executed. +- ``environments``: A string or list of strings indicating the operational environments in which the command runs. This + is *not* used by default, but may be used to programmatically filter commands for a specific environment. For example, + development versus live. +- ``prefix``: A statement to be added prior to executing the command. +- ``register``: A variable name to which the the success or failure (exit code) of the statement is captured. +- ``shell``: The shell used to run the commands. For example, ``/bin/bash``. This is generally not important, but can + be a problem when attempting to execute some commands (such as Django management commands). +- ``stop``: ``True`` indicates no other commands should be executed if the given command fails. +- ``sudo``: ``True`` indicates the command should be automatically prefixed with ``sudo``. If provided as a string, the + command is also prefixed with a specific user name. +- ``tags``: A list of tags used to classify the command. + +Defining an "Itemized" Command +------------------------------ + +Certain command definitions may be repeated by defining a list of items. + +Example of an "itemized" command: + +.. code-block:: ini + + [touch a bunch of files] + touch = /var/www/domains/example_com/www/$item + items = index.html, assets/index.html, content/index.html + +.. note:: + Command itemization may vary with the command type. + +Available Commands +------------------ + +The following commands instantiate command instances. Each example is shown with the defaults. + +.. include:: _command-examples.rst + +Pre-Parsing Command Files as Templates +====================================== + +Configuration file may be pre-processed as a Jinja2 template by providing a context dictionary: + +.. code-block:: ini + + [install apache] + install: apache + + [create the website directory] + mkdir: /var/www/domains/{{ domain_tld }}/www + recursive: yes + + [set permissions on the website directory] + perms: /var/www/domains/{{ domain_tld }}/www + group: www-data + mode: 775 + owner: www-data + +Then with a config instance: + +.. code-block:: python + + context = { + 'domain_tld': "example_com", + } + + config = Config("commands.ini", context=context) + config.load() + + for command in config.get_commands(): + print(command.get_statement(cd=True)) + print("") + +Using the Tease Command +======================= + +The ``tease`` command may be used to parse a configuration file, providing additional utilities for working with +commands. + +.. code-block:: text + +positional arguments: + path The path to the configuration file. + +optional arguments: + -h, --help show this help message and exit + -c, --color Enable code highlighting for terminal output. + -C= VARIABLES, --context= VARIABLES + Context variables for use in pre-parsing the config and templates. In the form of: name:value + -d, --docs Output documentation instead of code. + -D, --debug Enable debug output. + -f= FILTERS, --filter= FILTERS + Filter the commands in the form of: attribute:value + -O= OPTIONS, --option= OPTIONS + Common command options in the form of: name:value + -s, --script Output commands as a script. + -T= TEMPLATE_LOCATIONS, --template-path= TEMPLATE_LOCATIONS + The location of template files that may be used with the template command. + -w= OUTPUT_FILE, --write= OUTPUT_FILE + Write the output to disk. + -V= VARIABLES_FILE, --variables-file= VARIABLES_FILE + Load variables from a file. + -v Show version number and exit. + --version Show verbose version information and exit. + +The ``path`` argument defaults to ``commands.ini``. + +Loading Variables from a File +----------------------------- + +Context variables may be loaded from a file: + +.. code-block:: ini + + [domain] + name = example.com + tld = example_com + +The variables above are available as ``section_key``. For example, ``domain_name`` is ``example.com``. diff --git a/docs/source/developer.rst b/docs/source/developer.rst new file mode 100644 index 0000000..607c59e --- /dev/null +++ b/docs/source/developer.rst @@ -0,0 +1,110 @@ +.. _developer-reference: + +******************* +Developer Reference +******************* + +Constants +========= + +.. automodule:: scripttease.constants + :members: + :show-inheritance: + :special-members: __init__ + +Library +======= + +Commands +-------- + +.. automodule:: scripttease.library.commands.base + :members: + :show-inheritance: + :special-members: __init__ + +Overlays +-------- + +Common +...... + +.. automodule:: scripttease.library.overlays.common + :members: + :show-inheritance: + :special-members: __init__ + +Django +...... + +.. automodule:: scripttease.library.overlays.django + :members: + :show-inheritance: + :special-members: __init__ + +Postgres +........ + +.. automodule:: scripttease.library.overlays.pgsql + :members: + :show-inheritance: + :special-members: __init__ + +Posix +..... + +.. automodule:: scripttease.library.overlays.posix + :members: + :show-inheritance: + :special-members: __init__ + +Ubuntu +...... + +.. automodule:: scripttease.library.overlays.ubuntu + :members: + :show-inheritance: + :special-members: __init__ + +Scripts +------- + +.. automodule:: scripttease.library.scripts + :members: + :show-inheritance: + :special-members: __init__ + +Factory +======= + +.. automodule:: scripttease.factory + :members: + :show-inheritance: + :special-members: __init__ + +Parsers +======= + +Base +---- + +.. automodule:: scripttease.parsers.base + :members: + :show-inheritance: + :special-members: __init__ + +Config (INI) +------------ + +.. automodule:: scripttease.parsers.ini + :members: + :show-inheritance: + :special-members: __init__ + +Utils +----- + +.. automodule:: scripttease.parsers.utils + :members: + :show-inheritance: + :special-members: __init__ diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..dd31ea4 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,17 @@ +Python Script Tease +=================== + +.. toctree:: + :maxdepth: 2 + + Introduction + Configuration + Developer Reference + Tests + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000..1380e78 --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,27 @@ +.. _introduction: + +************ +Introduction +************ + +Script Tease is a library and command line tool for generating commands programmatically or using configuration files. + +Concepts +======== + +Generating Commands +------------------- + +Script Tease may be used in two (2) ways: + +1. Using the library to programmatically define commands and export them as command line statements. See + :ref:`developer-reference`. +2. Using the ``tease`` command to generate commands from a configuration file. See :ref:`configuration`. + +Overlays +-------- + +An *overlay* is a set of command meta functions that define the capabilities of a specific operating system. + +.. note:: + At present, the only fully defined overlay is for Ubuntu. diff --git a/docs/source/tests.rst b/docs/source/tests.rst new file mode 100644 index 0000000..ce80704 --- /dev/null +++ b/docs/source/tests.rst @@ -0,0 +1,54 @@ +***** +Tests +***** + +Coverage Requirements +===================== + +100% coverage is required for the ``master`` branch. + +See `current coverage report `_. + +.. csv-table:: Lines of Code + :file: _data/cloc.csv + +Set Up for Testing +================== + +Install requirements: + +.. code-block:: bash + + pip install tests/requirements.pip + +Running Tests +============= + +.. tip:: + You may use the ``tests`` target of the ``Makefile`` to run tests with coverage: + + ``make tests;`` + +To run unit tests: + +.. code-block:: bash + + python -m pytest; + +Run a specific test: + +.. code-block:: bash + + python -m pytest tests/units/path/to/test.py + +To allow output from print statements within a test method, add the ``-s`` switch: + +.. code-block:: bash + + python -m pytest -s tests/units/path/to/test.py + +Reference +========= + +- `coverage `_ +- `pytest `_ diff --git a/sandbox/tease.py b/sandbox/tease.py new file mode 100755 index 0000000..d106be4 --- /dev/null +++ b/sandbox/tease.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python + +import re +import sys + +sys.path.insert(0, "../") + +from scripttease.cli import main_command + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main_command()) diff --git a/scripttease/cli/__init__.py b/scripttease/cli/__init__.py index e69de29..dd16d59 100644 --- a/scripttease/cli/__init__.py +++ b/scripttease/cli/__init__.py @@ -0,0 +1,204 @@ +# Imports + +from argparse import ArgumentParser, RawDescriptionHelpFormatter +from superpython.logging import LoggingHelper +from ..constants import LOGGER_NAME +from . import initialize +from . import subcommands + +DEBUG = 10 + +logging = LoggingHelper(colorize=True, name=LOGGER_NAME) +log = logging.setup() + +# Commands + + +def main_command(): + """Process script configurations.""" + + __author__ = "Shawn Davis " + __date__ = "2020-07-21" + __help__ = """NOTES + +This command is used to parse configuration files and output the commands. + + """ + __version__ = "0.10.0-d" + + # Main argument parser from which sub-commands are created. + parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter) + + parser.add_argument( + "path", + default="commands.ini", + nargs="?", + help="The path to the configuration file." + ) + + parser.add_argument( + "-c", + "--color", + action="store_true", + dest="color_enabled", + help="Enable code highlighting for terminal output." + ) + + parser.add_argument( + "-C=", + "--context=", + action="append", + dest="variables", + help="Context variables for use in pre-parsing the config and templates. In the form of: name:value" + ) + + parser.add_argument( + "-d", + "--docs", + action="store_true", + dest="docs_enabled", + help="Output documentation instead of code." + ) + + # parser.add_argument( + # "-d=", + # "--docs=", + # choices=["html", "markdown", "plain", "rst"], + # dest="docs_enabled", + # help="Output documentation instead of code." + # ) + + parser.add_argument( + "-D", + "--debug", + action="store_true", + dest="debug_enabled", + help="Enable debug output." + ) + + parser.add_argument( + "-f=", + "--filter=", + action="append", + dest="filters", + help="Filter the commands in the form of: attribute:value" + ) + + parser.add_argument( + "-O=", + "--option=", + action="append", + dest="options", + help="Common command options in the form of: name:value" + ) + + # parser.add_argument( + # "-O=", + # "--output=", + # # default=os.path.join("prototype", "output"), + # dest="output_path", + # help="Output to the given directory. Defaults to ./prototype/output/" + # ) + + parser.add_argument( + "-s", + "--script", + action="store_true", + dest="script_enabled", + help="Output commands as a script." + ) + + parser.add_argument( + "-T=", + "--template-path=", + action="append", + dest="template_locations", + help="The location of template files that may be used with the template command." + ) + + parser.add_argument( + "-w=", + "--write=", + dest="output_file", + help="Write the output to disk." + ) + + parser.add_argument( + "-V=", + "--variables-file=", + dest="variables_file", + help="Load variables from a file." + ) + + # Access to the version number requires special consideration, especially + # when using sub parsers. The Python 3.3 behavior is different. See this + # answer: http://stackoverflow.com/questions/8521612/argparse-optional-subparser-for-version + parser.add_argument( + "-v", + action="version", + help="Show version number and exit.", + version=__version__ + ) + + parser.add_argument( + "--version", + action="version", + help="Show verbose version information and exit.", + version="%(prog)s" + " %s %s by %s" % (__version__, __date__, __author__) + ) + + # Parse arguments. + args = parser.parse_args() + + if args.debug_enabled: + log.setLevel(DEBUG) + + log.debug("Namespace: %s" % args) + + # Load context. + context = dict() + if args.variables: + context = initialize.context_from_cli(args.variables) + + # Handle filters. + filters = None + if args.filters: + filters = initialize.filters_from_cli(args.filters) + + # Handle options. + options = None + if args.options: + options = initialize.options_from_cli(args.options) + + if args.variables_file: + variables = initialize.variable_from_file(args.variables_file) + if variables: + context.update(variables) + + if args.docs_enabled: + exit_code = subcommands.output_docs( + args.path, + context=context, + filters=filters, + locations=args.template_locations, + options=options + ) + elif args.script_enabled: + exit_code = subcommands.output_script( + args.path, + color_enabled=args.color_enabled, + context=context, + locations=args.template_locations, + options=options + ) + else: + exit_code = subcommands.output_commands( + args.path, + color_enabled=args.color_enabled, + context=context, + filters=filters, + locations=args.template_locations, + options=options + ) + + exit(exit_code) diff --git a/scripttease/cli/initialize.py b/scripttease/cli/initialize.py new file mode 100644 index 0000000..4ec2f87 --- /dev/null +++ b/scripttease/cli/initialize.py @@ -0,0 +1,58 @@ +# Imports + +from configparser import ConfigParser +import logging +import os +from superpython.utils import smart_cast +from ..constants import LOGGER_NAME + +log = logging.getLogger(LOGGER_NAME) + +# Functions + + +def context_from_cli(variables): + context = dict() + for i in variables: + key, value = i.split(":") + context[key] = smart_cast(value) + + return context + + +def filters_from_cli(filters): + _filters = dict() + for i in filters: + key, value = i.split(":") + if key not in filters: + _filters[key] = list() + + _filters[key].append(value) + + return _filters + + +def options_from_cli(options): + _options = dict() + for i in options: + key, value = i.split(":") + _options[key] = smart_cast(value) + + return _options + + +def variable_from_file(path): + if not os.path.exists(path): + log.warning("Variables file does not exist: %s" % path) + return None + + ini = ConfigParser() + ini.read(path) + + variables = dict() + for section in ini.sections(): + for key, value in ini.items(section): + key = "%s_%s" % (section, key) + variables[key] = smart_cast(vaue) + + return variables diff --git a/scripttease/cli/subcommands.py b/scripttease/cli/subcommands.py new file mode 100644 index 0000000..3e5180c --- /dev/null +++ b/scripttease/cli/subcommands.py @@ -0,0 +1,76 @@ +# Imports + +from superpython.shell import EXIT +from superpython.utils import highlight_code +from ..parsers import load_commands, load_config + +# Functions + + +def output_commands(path, color_enabled=False, context=None, filters=None, locations=None, options=None): + commands = load_commands( + path, + context=context, + filters=filters, + locations=locations, + options=options + ) + if commands is None: + return EXIT.ERROR + + output = list() + for command in commands: + statement = command.get_statement(cd=True) + if statement is None: + continue + + output.append(statement) + output.append("") + + if color_enabled: + print(highlight_code("\n".join(output), language="bash")) + else: + print("\n".join(output)) + + return EXIT.OK + + +def output_docs(path, context=None, filters=None, locations=None, options=None): + commands = load_commands( + path, + context=context, + filters=filters, + locations=locations, + options=options + ) + if commands is None: + return EXIT.ERROR + + count = 1 + output = list() + for command in commands: + output.append("%s. %s" % (count, command.comment)) + count += 1 + + print("\n".join(output)) + + return EXIT.OK + + +def output_script(path, color_enabled=False, context=None, filters=None, locations=None, options=None): + config = load_config( + path, + context=context, + locations=locations, + options=options + ) + if config is None: + return EXIT.ERROR + + script = config.as_script() + if color_enabled: + print(highlight_code(script.to_string(), language="bash")) + else: + print(script) + + return EXIT.OK diff --git a/scripttease/data/overlays/ubuntu.ini b/scripttease/data/overlays/ubuntu.ini deleted file mode 100644 index fb6a79a..0000000 --- a/scripttease/data/overlays/ubuntu.ini +++ /dev/null @@ -1,47 +0,0 @@ -[apache] -disable_module = a2dismod {{ module_name }} -disable_site = a2dissite {{ domain_name }}.conf -enable_module = a2enmod {{ module_name }} -enable_site = a2ensite {{ domain_name }}.conf -reload = service apache2 reload -restart = service apache2 restart -start = service apache2 start -stop = service apache2 stop -test = apachectl configtest - -[package_install] -system = apt-get install -y {{ package_name }} -pip = pip3 install{% if upgrade %} --upgrade{% endif %} --quiet {{ package_name }} - -[package_remove] -system = apt-get uninstall -y {{ package_name }} -pip = pip3 uninstall --quiet {{ package_name }} - -[system] -install = apt-get install -y {{ package_name }} -reboot = reboot -remove = apt-get uninstall -y {{ package_name }} -update = apt-get update -y -upgrade = apt-get upgrade -y - -[python] -virtualenv = virtualenv {{ name }} -install = pip3 install{% if upgrade %} --upgrade{% endif %} --quiet {{ package_name }} -remove = pip3 uninstall --quiet {{ package_name }} - -[files] -append = echo "{{ content }}" >> {{ path }} -chgrp = chgrp{% if recursive %} -R{% endif %} {{ group }} {{ path }} -chmod = chmod{% if recursive %} -R{% endif %} {{ owner }} {{ path }} -chown = chown{% if recursive %} -R{% endif %} {{ mode }} {{ path }} -copy = cp{% if recursive %} -R{% endif %}{% if overwrite %} -n{% endif %} {{ from_path }} {{ to_path }} -mkdir = mkdir{% if mode %} -m {{ mode }}{% endif %}{% if recursive %} -p{% endif %} {{ path }} -move = move {{ from_path }} {{ to_path }} -rename = move {{ from_path }} {{ to_path }} -remove = rm{% if force %} -f{% endif %}{% if recursive %} -r{% endif %} {{ path }} -;rsync = ? -;scopy = ? -;sed = ? -symlink = ln -s{% if force %} -f{% endif %} {{ source }} {{ target }} -touch = touch {{ path }} -;write = ? diff --git a/scripttease/library/factory.py b/scripttease/factory.py similarity index 63% rename from scripttease/library/factory.py rename to scripttease/factory.py index 3f0c8fa..c14b4b1 100644 --- a/scripttease/library/factory.py +++ b/scripttease/factory.py @@ -1,6 +1,11 @@ # Imports +import logging from importlib import import_module +from .constants import LOGGER_NAME +from .library.commands import ItemizedCommand + +log = logging.getLogger(LOGGER_NAME) # Exports @@ -25,6 +30,9 @@ class Factory(object): self.overlay = None self._overlay = overlay + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self._overlay) + def get_command(self, name, *args, **kwargs): """Get a command. @@ -37,19 +45,19 @@ class Factory(object): """ if not self.overlay.command_exists(name): - # log.warning("Command does not exist in %s overlay: %s" % (overlay.name, name)) + log.warning("Command does not exist in %s overlay: %s" % (self._overlay, name)) return None callback = self.overlay.MAPPINGS[name] try: - # items = kwargs.pop("items", None) - # if items is not None: - # return ItemizedCommand(callback, items, *args, **kwargs) + items = kwargs.pop("items", None) + if items is not None: + return ItemizedCommand(callback, items, *args, **kwargs) return callback(*args, **kwargs) - except (KeyError, TypeError, ValueError) as e: - # log.critical("Failed to load %s command: %s" % (name, e)) + except (KeyError, NameError, TypeError, ValueError) as e: + log.critical("Failed to load %s command: %s" % (name, e)) return None def load(self): @@ -62,7 +70,7 @@ class Factory(object): self.overlay = import_module("scripttease.library.overlays.%s" % self._overlay) self.is_loaded = True except ImportError as e: - # log.error("The %s overlay could not be imported: %s" % (overlay, str(e))) + log.error("The %s overlay could not be imported: %s" % (self._overlay, str(e))) pass - return self.is_loaded \ No newline at end of file + return self.is_loaded diff --git a/scripttease/library/_overlays.py b/scripttease/library/_overlays.py deleted file mode 100644 index 6177359..0000000 --- a/scripttease/library/_overlays.py +++ /dev/null @@ -1,224 +0,0 @@ -# Imports - -from configparser import RawConfigParser -import os -from superpython.utils import parse_jinja_string -from ..constants import PATH_TO_SCRIPT_TEASE - -# Exports - -__all__ = ( - "Overlay", -) - -# Classes - - -class Overlay(object): - """An overlay applies commands specific to a given operating system or platform.""" - - def __init__(self, name): - self.is_loaded = False - self._name = name - self._path = os.path.join(PATH_TO_SCRIPT_TEASE, "data", "overlays", "%s.ini" % name) - self._sections = dict() - - def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, self._name) - - @property - def exists(self): - """Indicates whether the overlay file exists. - - :rtype: bool - - """ - return os.path.exists(self._path) - - def get(self, section, key, **kwargs): - """Get the command statement for the given section and key. - - :param section: The section name. - :type section: str - - :param key: The key within the section. - :type key: str - - kwargs are used to parse the value of the key within the section. - - :rtype: str | None - - """ - if not self.has(section, key): - return None - - template = self._sections[section][key] - - return parse_jinja_string(template, kwargs) - - def has(self, section, key): - """Determine whether the overlay contains a given section and key. - - :param section: The section name. - :type section: str - - :param key: The key within the section. - :type key: str - - :rtype: bool - - """ - if section not in self._sections: - return False - - if key not in self._sections[section]: - return False - - return True - - def load(self): - """Load the overlay. - - :rtype: bool - - """ - if not self.exists: - return False - - ini = RawConfigParser() - ini.read(self._path) - - for section in ini.sections(): - self._sections[section] = dict() - for key, value in ini.items(section): - self._sections[section][key] = value - - self.is_loaded = True - return True - - def to_mapping(self): - """Export the overlay as a dictionary with command names as values. - - :rtype: dict - - """ - d = dict() - for section in self._sections: - d[section] = list() - for command_name, statement in self._sections[section].items(): - d[section].append(command_name) - - -class Overlay2(object): - """An overlay applies commands specific to a given operating system or platform.""" - - def __init__(self, name): - self.is_loaded = False - self._name = name - self._path = os.path.join(PATH_TO_SCRIPT_TEASE, "data", "overlays", "%s.ini" % name) - self._sections = dict() - - self.exists = os.path.exists(self._path) - - def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, self._name) - - def command_exists(self, name): - """Determine whether a given command exists. - - :param name: The name of the command to check. - :type name: str - - :rtype: bool - - """ - section = None - if "." in name: - section, name = name.split(".") - - if section is not None: - if section in self._sections: - return name in self._sections[section] - - for section in self._sections.keys(): - if name in self._sections[section]: - return True - - return False - - def get_statement(self, name, *args, **kwargs): - pass - - def get(self, section, key, **kwargs): - """Get the command statement for the given section and key. - - :param section: The section name. - :type section: str - - :param key: The key within the section. - :type key: str - - kwargs are used to parse the value of the key within the section. - - :rtype: str | None - - """ - if not self.has(section, key): - return None - - template = self._sections[section][key] - - return parse_jinja_string(template, kwargs) - - def has(self, section, key): - """Determine whether the overlay contains a given section and key. - - :param section: The section name. - :type section: str - - :param key: The key within the section. - :type key: str - - :rtype: bool - - """ - if section not in self._sections: - return False - - if key not in self._sections[section]: - return False - - return True - - def load(self): - """Load the overlay. - - :rtype: bool - - """ - if not self.exists: - return False - - ini = RawConfigParser() - ini.read(self._path) - - for section in ini.sections(): - self._sections[section] = dict() - for command_name, statement_template in ini.items(section): - self._sections[section][command_name] = statement_template - - self.is_loaded = True - - return True - - def to_mapping(self): - """Export the overlay as a dictionary with command names as values. - - :rtype: dict - - """ - d = dict() - for section in self._sections: - d[section] = list() - for command_name, statement in self._sections[section].items(): - d[section].append(command_name) \ No newline at end of file diff --git a/scripttease/library/commands/__init__.py b/scripttease/library/commands/__init__.py index 9fbd74a..1084628 100644 --- a/scripttease/library/commands/__init__.py +++ b/scripttease/library/commands/__init__.py @@ -1,2 +1,2 @@ from .base import Command, ItemizedCommand -from .factory import command_factory +# from .factory import command_factory diff --git a/scripttease/library/commands/apache.py b/scripttease/library/commands/apache.py deleted file mode 100644 index 878f45d..0000000 --- a/scripttease/library/commands/apache.py +++ /dev/null @@ -1,134 +0,0 @@ -# Imports - -import logging -from ...constants import LOGGER_NAME -from .base import Command - -log = logging.getLogger(LOGGER_NAME) - -# Exports - -__all__ = ( - "MAPPING", - "ConfigTest", - "DisableModule", - "DisableSite", - "EnableModule", - "EnableSite", - "Reload", - "Restart", - "Start", - "Stop", -) - -# Classes - - -class ConfigTest(Command): - """Run an apache config test.""" - - def __init__(self, overlay=None, **kwargs): - """There is no argument.""" - if overlay is not None: - statement = overlay.get("apache", "test") - else: - statement = "apachectl configtest" - - kwargs.setdefault('register', "apache_checks_out") - - super().__init__(statement, **kwargs) - - -class DisableModule(Command): - """Disable an Apache module.""" - - def __init__(self, module_name, overlay=None, **kwargs): - """Initialize the command. - - :param module_name: The module name. - :type module_name: str - - """ - if overlay is not None: - statement = overlay.get("apache", "disable_module", module_name=module_name) - - statement = "a2dismod %s" % module_name - - super().__init__(statement, **kwargs) - - -class DisableSite(Command): - """Disable a virtual host.""" - - def __init__(self, domain_name, **kwargs): - """Initialize the command. - - :param domain_name: The domain name. - :type domain_name: str - - """ - statement = "a2dissite %s.conf" % domain_name - - super().__init__(statement, **kwargs) - - -class Enable(Command): - - def __init__(self, what, name, **kwargs): - if what in ("mod", "module"): - statement = EnableModule(name, **kwargs).statement - elif what == "site": - statement = EnableSite(name, **kwargs).statement - else: - raise ValueError("Invalid Apache item to be enabled: %s" % what) - - super().__init__(statement, **kwargs) - - -class EnableModule(Command): - """Enable an Apache module.""" - - def __init__(self, module_name, **kwargs): - """Initialize the command. - - :param module_name: The module name. - :type module_name: str - - """ - statement = "a2enmod %s" % module_name - - super().__init__(statement, **kwargs) - - -class EnableSite(Command): - """Enable a virtual host.""" - - def __init__(self, domain_name, **kwargs): - """Initialize the command. - - :param domain_name: The domain name. - :type domain_name: str - - """ - statement = "a2ensite %s.conf" % domain_name - - super().__init__(statement, **kwargs) - - -MAPPING = { - # 'apache': Apache, - 'apache.check': ConfigTest, - 'apache.config': ConfigTest, - 'apache.configtest': ConfigTest, - 'apache.disable': Disable, - 'apache.disable_mod': DisableModule, - 'apache.disable_module': DisableModule, - 'apache.disable_site': DisableSite, - 'apache.enable': Enable, - 'apache.enable_mod': EnableModule, - 'apache.enable_module': EnableModule, - 'apache.enable_site': EnableSite, - 'apache.mod': EnableModule, - 'apache.module': EnableModule, - 'apache.test': ConfigTest, -} diff --git a/scripttease/library/commands/base.py b/scripttease/library/commands/base.py index d4b5fba..cab46aa 100644 --- a/scripttease/library/commands/base.py +++ b/scripttease/library/commands/base.py @@ -2,9 +2,52 @@ class Command(object): + """A command line statement.""" def __init__(self, statement, comment=None, condition=None, cd=None, environments=None, function=None, prefix=None, register=None, shell=None, stop=False, sudo=None, tags=None, **kwargs): + """Initialize a command. + + :param statement: The statement to be executed. + :type statement: str + + :param comment: A comment regarding the statement. + :type comment: str + + :param condition: A (system-specific) condition for the statement to be executed. + :type condition: str + + :param cd: The direction from which the statement should be executed. + :type cd: str + + :param environments: A list of target environments where the statement should be executed. + :type environments: list[str] + + :param function: The name of the function in which the statement is executed. + :type function: str + + :param prefix: A statement to execute before the main statement is executed. + :type prefix: str + + :param register: A variable name to use for capture the success for failure of the statement's execution. + :type register: str + + :param shell: The shell execute through which the statement is executed. + :type shell: str + + :param stop: Indicates process should stop if the statement fails to execute. + :type stop: bool | None + + :param sudo: Indicates whether sudo should be invoked for the statement. Given as a bool or user name or + :py:class:`scripttease.library.commands.base.Sudo` instance. + :type sudo: bool | str | Sudo + + :param tags: A list of tags describing the statement. + :type tags: list[str] + + Additional kwargs are available as dynamic attributes of the Command instance. + + """ self.comment = comment self.condition = condition self.cd = cd @@ -37,12 +80,15 @@ class Command(object): return "<%s>" % self.__class__.__name__ - def get_statement(self, cd=False): + def get_statement(self, cd=False, suppress_comment=False): """Get the full statement. :param cd: Include the directory change, if given. :type cd: bool + :param suppress_comment: Don't include the comment. + :type suppress_comment: bool + :rtype: str """ @@ -65,7 +111,7 @@ class Command(object): a.append(")") b = list() - if self.comment is not None: + if self.comment is not None and not suppress_comment: b.append("# %s" % self.comment) if self.condition is not None: @@ -95,12 +141,12 @@ class Command(object): class ItemizedCommand(object): + """An itemized command represents multiple commands of with the same statement but different parameters.""" - def __init__(self, command_class, items, *args, **kwargs): + def __init__(self, callback, items, *args, **kwargs): """Initialize the command. - :param command_class: The command class to be used. - :type command_class: class + :param callback: The function to be used to generate the command. :param items: The command arguments. :type items: list[str] @@ -111,7 +157,7 @@ class ItemizedCommand(object): """ self.args = args - self.command_class = command_class + self.callback = callback self.items = items self.kwargs = kwargs @@ -119,7 +165,7 @@ class ItemizedCommand(object): return self.kwargs.get(item) def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, self.command_class.__name__) + return "<%s %s>" % (self.__class__.__name__, self.callback.__name__) def get_commands(self): """Get the commands to be executed. @@ -135,7 +181,7 @@ class ItemizedCommand(object): for arg in self.args: args.append(arg.replace("$item", item)) - command = self.command_class(*args, **kwargs) + command = self.callback(*args, **kwargs) a.append(command) return a diff --git a/scripttease/library/commands/factory.py b/scripttease/library/commands/factory.py deleted file mode 100644 index d0f84b0..0000000 --- a/scripttease/library/commands/factory.py +++ /dev/null @@ -1,119 +0,0 @@ -# Imports - -from importlib import import_module -import logging -# from ..scripts import Function -from ...constants import LOGGER_NAME -# from .base import ItemizedCommand -# from .mappings import MAPPING - -log = logging.getLogger(LOGGER_NAME) - -# Functions - - -def command_factory(name, comment, overlay, *args, **kwargs): - # try: - # _overlay = import_module("scripttease.library.overlays.%s" % overlay) - # except ImportError as e: - # log.error("The %s overlay could not be imported: %s" % (overlay, str(e))) - # return None - - if not overlay.command_exists(name): - log.warning("Command does not exist in %s overlay: %s" % (overlay.name, name)) - return None - - kwargs['comment'] = comment - - callback = overlay.MAPPINGS[name] - return callback(*args, **kwargs) - -''' -def command_exists(name): - """Indicates whether the named command exists. - - :param name: The name of the command to be checked. - :type name: str - - :rtype: bool - - """ - return name in MAPPING - - -def command_factory(name, comment, overlay, *args, **kwargs): - # if name in ("func", "function"): - # kwargs['comment'] = comment - # return Function(*args, **kwargs) - - if not command_exists(name): - log.warning("No mapping for command: %s" % name) - return None - - _args = list(args) - kwargs['comment'] = comment - kwargs['overlay'] = overlay - - log.debug("%s: %s" % (comment, kwargs)) - - command_class = MAPPING[name] - try: - items = kwargs.pop("items", None) - if items is not None: - return ItemizedCommand(command_class, items, *_args, **kwargs) - - return command_class(*_args, **kwargs) - except (KeyError, TypeError, ValueError) as e: - log.critical("Failed to load %s command: %s" % (name, e)) - return None -''' - -# -# -# -# MAPPINGS = { -# 'apache.disable_module': apache_disable_module, -# 'apache.disable_site': apache_disable_site, -# 'apache.enable_module': apache_enable_module, -# 'apache.enable_site': apache_enable_site, -# 'apache.reload': apache_reload, -# 'apache.restart': apache_restart, -# 'apache.start': apache_start, -# 'apache.stop': apache_stop, -# 'apache.test': apache_test, -# 'copy': file_copy, -# 'pip': python_pip, -# 'virtualenv': python_virtualenv, -# # 'python': ("pip", "virtualenv"), -# # 'apache': ("disable_module", "disable_site", "enable_module", "enable_site", "test"), -# } - - -def nother_command_exists(name): - return name in MAPPINGS - - -def other_command_exists(name, section=None): - if section is not None: - if section not in MAPPINGS: - return False - - return name in MAPPINGS[section] - - for _section, commands in MAPPINGS.items(): - if name in commands: - return True - - return False - - - -def other_command_factory(name, comment, overlay, *args, **kwargs): - if not overlay.command_exists(name): - log.warning("The %s overlay does not have a mapping for command: %s" % (overlay, name)) - return None - - items = kwargs.pop("items", None) - if items is not None: - return ItemizedCommand - diff --git a/scripttease/library/commands/mappings.py b/scripttease/library/commands/mappings.py deleted file mode 100644 index 6cfc978..0000000 --- a/scripttease/library/commands/mappings.py +++ /dev/null @@ -1,4 +0,0 @@ -from .python import MAPPING as PYTHON_MAPPING - -MAPPING = dict() -MAPPING.update(PYTHON_MAPPING) diff --git a/scripttease/library/commands/packages.py b/scripttease/library/commands/packages.py deleted file mode 100644 index a23d255..0000000 --- a/scripttease/library/commands/packages.py +++ /dev/null @@ -1,23 +0,0 @@ -# Classes - - -class Install(object): - - def __init__(self, name, manager="pip", overlay=None, upgrade=False, **kwargs): - if overlay is not None: - statement = overlay.get("package_install", manager, package_name=name, upgrade=upgrade) - else: - statement = "%s install %s" % (manager, name) - - self.statement = statement - - -class Remove(object): - - def __init__(self, name, manager="pip", overlay=None): - if overlay is not None: - statement = overlay.get("package_remove", manager, package_name=name) - else: - statement = "%s uninstall %s" % (manager, name) - - diff --git a/scripttease/library/commands/python.py b/scripttease/library/commands/python.py deleted file mode 100644 index 3c9fc6d..0000000 --- a/scripttease/library/commands/python.py +++ /dev/null @@ -1,43 +0,0 @@ -# Imports - -from .base import Command - -# Exports - -__all__ = ( - "Pip", - "VirtualEnv", -) - -# Classes - - -class Pip(Command): - - def __init__(self, name, op="install", overlay=None, upgrade=False, venv=None, **kwargs): - if overlay is not None: - statement = overlay.get("python", op, package_name=name, upgrade=upgrade) - else: - statement = "pip %s -y %s" % (op, name) - - if venv is not None: - kwargs['prefix'] = "source %s/bin/activate" % venv - - kwargs.setdefault("comment", "%s %s" % (op, name)) - - super().__init__(statement, **kwargs) - - -class VirtualEnv(Command): - - def __init__(self, name="python", overlay=None, **kwargs): - kwargs.setdefault("comment", "create %s virtual environment" % name) - - statement = "virtualenv %s" % name - super().__init__(statement, **kwargs) - - -MAPPING = { - 'pip': Pip, - 'virtualenv': VirtualEnv, -} diff --git a/scripttease/library/overlays/common.py b/scripttease/library/overlays/common.py index 6948231..045a40b 100644 --- a/scripttease/library/overlays/common.py +++ b/scripttease/library/overlays/common.py @@ -1,5 +1,17 @@ +# Imports + from ..commands import Command +# Exports + +__all__ = ( + "COMMON_MAPPINGS", + "python_pip", + "python_virtualenv", +) + +# Functions + def python_pip(name, op="install", upgrade=False, venv=None, **kwargs): if upgrade: @@ -19,3 +31,10 @@ def python_virtualenv(name="python", **kwargs): kwargs.setdefault("comment", "create %s virtual environment" % name) return Command("virtualenv %s" % name, **kwargs) + +# Mappings + +COMMON_MAPPINGS = { + 'pip': python_pip, + 'virtualenv': python_virtualenv, +} diff --git a/scripttease/library/overlays/django.py b/scripttease/library/overlays/django.py new file mode 100644 index 0000000..9ceaf32 --- /dev/null +++ b/scripttease/library/overlays/django.py @@ -0,0 +1,164 @@ +# Imports + +import os +from ..commands import Command + +# Exports + +__all__ = ( + "DJANGO_MAPPINGS", + "django", + "django_check", + "django_collect_static", + "django_dumpdata", + "django_loaddata", + "django_migrate", +) + +# Functions + + +def _django(name, *args, venv=None, **kwargs): + if venv is not None: + kwargs['prefix'] = "source %s/bin/activate" % venv + + kwargs.setdefault("comment", "run %s django management command" % name) + + # Base parameters need to be captured, because all others are assumed to be switches for the management command. + _kwargs = { + 'comment': kwargs.pop("comment", None), + 'condition': kwargs.pop("condition", None), + 'cd': kwargs.pop("cd", None), + 'environments': kwargs.pop("environments", None), + 'function': kwargs.pop("function", None), + # 'local': kwargs.pop("local", False), + 'prefix': kwargs.pop("prefix", None), + 'register': kwargs.pop("register", None), + 'shell': kwargs.pop("shell", "/bin/bash"), + 'stop': kwargs.pop("stop", False), + 'sudo': kwargs.pop('sudo', False), + 'tags': kwargs.pop("tags", None), + } + + statement = list() + statement.append("./manage.py %s" % name) + + # Remaining kwargs are assumed to be switches. + for key, value in kwargs.items(): + key = key.replace("_", "-") + if type(value) is bool: + if value is True: + statement.append("--%s" % key) + else: + statement.append("--%s=%s" % (key, value)) + + if len(args) > 0: + statement.append(" ".join(args)) + + return Command(" ".join(statement), **_kwargs) + + +def django(name, *args, venv=None, **kwargs): + if name == "check": + return django_check(venv=venv, **kwargs) + elif name in ("collectstatic", "static"): + return django_collect_static(venv=venv, **kwargs) + elif name == "migrate": + return django_migrate(venv=venv, **kwargs) + else: + return _django(name, *args, venv=venv, **kwargs) + + +def django_check(venv=None, **kwargs): + kwargs.setdefault("comment", "run django checks") + kwargs.setdefault("register", "django_checks_out") + + return _django("check", venv=venv, **kwargs) + + +def django_collect_static(venv=None, **kwargs): + kwargs.setdefault("comment", "collect static files") + + return _django("collectstatic", venv=venv, **kwargs) + + +def django_dumpdata(app_name, base_path="local", file_name="initial", indent=4, natural_foreign=False, + natural_primary=False, path=None, venv=None, **kwargs): + """Initialize the command. + + :param app_name: The name (app label) of the app. ``app_label.ModelName`` may also be given. + :type app_name: str + + :param file_name: The file name to which the data will be dumped. + :type file_name: str + + :param indent: Indentation of the exported fixtures. + :type indent: int + + :param natural_foreign: Use the natural foreign parameter. + :type natural_foreign: bool + + :param natural_primary: Use the natural primary parameter. + :type natural_primary: bool + + :param path: The path to the data file. + :type path: str + + """ + kwargs.setdefault("comment", "export fixtures for %s" % app_name) + + output_format = kwargs.pop("format", "json") + + _path = path or os.path.join(base_path, app_name, "fixtures", "%s.%s" % (file_name, output_format)) + + return _django( + "dumpdata", + app_name, + "> %s" % _path, + format=output_format, + indent=indent, + natural_foreign=natural_foreign, + natural_primary=natural_primary, + venv=venv, + **kwargs + ) + + +def django_loaddata(app_name, base_path="local", file_name="initial", path=None, venv=None, **kwargs): + """Initialize the command. + + :param app_name: The name (app label) of the app. + :type app_name: str + + :param file_name: The file name to which the data will be dumped. + :type file_name: str + + :param path: The path to the data file. + :type path: str + + """ + kwargs.setdefault("comment", "load fixtures for %s" % app_name) + + output_format = kwargs.pop("format", "json") + + _path = path or os.path.join(base_path, app_name, "fixtures", "%s.%s" % (file_name, output_format)) + + return _django("loaddata", _path, venv=venv, **kwargs) + + +def django_migrate(venv=None, **kwargs): + kwargs.setdefault("comment", "run django database migrations") + + return _django("migrate", venv=venv, **kwargs) + +# Mapping + + +DJANGO_MAPPINGS = { + 'django': django, + 'django.check': django_check, + 'django.collect_static': django_collect_static, + 'django.dumpdata': django_dumpdata, + 'django.loaddata': django_loaddata, + 'django.migrate': django_migrate, +} diff --git a/scripttease/library/overlays/pgsql.py b/scripttease/library/overlays/pgsql.py new file mode 100644 index 0000000..74f470e --- /dev/null +++ b/scripttease/library/overlays/pgsql.py @@ -0,0 +1,292 @@ +# Imports + +from ..commands import Command + +# Exports + +__all__ = ( + "PGSQL_MAPPINGS", + "pg_create_database", + "pg_create_user", + "pg_database_exists", + "pg_drop_database", + "pg_drop_user", + "pg_dump_database", + "psql", +) + +# Functions + + +def _get_pgsql_command(name, host="localhost", password=None, port=5432, user="postgres"): + a = list() + + if password: + a.append('export PGPASSWORD="%s" &&' % password) + + a.append(name) + + a.append("--host=%s" % host) + a.append("--port=%s" % port) + a.append("--username=%s" % user) + + return a + + +def pg_create_database(name, admin_pass=None, admin_user="postgres", host="localhost", owner=None, port=5432, + template=None, **kwargs): + """Create a PostgreSQL database. + + :param name: The database name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param owner: The owner (user/role name) of the new database. + :type owner: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + :param template: The database template name to use, if any. + :type template: str + + """ + _owner = owner or admin_user + + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("createdb", host=host, password=admin_pass, port=port) + base.append("--owner=%s" % _owner) + + if template is not None: + base.append("--template=%s" % template) + + base.append(name) + + return Command(" ".join(base), **kwargs) + + +def pg_create_user(name, admin_pass=None, admin_user="postgres", host="localhost", password=None, port=5432, **kwargs): + """Create a PostgreSQL user. + + :param name: The user name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param password: The password for the new user. + :type password: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + """ + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("createuser", host=host, password=admin_pass, port=port) + base.append("-DRS") + base.append(name) + + if password is not None: + base.append("&& psql -h %s -U %s" % (host, admin_user)) + base.append("-c \"ALTER USER %s WITH ENCRYPTED PASSWORD '%s';\"" % (name, password)) + + return Command(" ".join(base), **kwargs) + + +def pg_database_exists(name, admin_pass=None, admin_user="postgres", host="localhost", port=5432, **kwargs): + """Determine if a Postgres database exists. + + :param name: The database name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + """ + # Postgres commands always run without sudo because the -U may be provided. However, sudo may be required for + # file writing. + # kwargs['sudo'] = False + + kwargs.setdefault("register", "%s_db_exists" % name) + + base = _get_pgsql_command("psql", host=host, password=admin_pass, port=port, user=admin_user) + base.append(r"-lqt | cut -d \| -f 1 | grep -qw %s" % name) + + return Command(" ".join(base), **kwargs) + + +def pg_drop_database(name, admin_pass=None, admin_user="postgres", host="localhost", port=5432, **kwargs): + """Remove a PostgreSQL database. + + :param name: The database name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + """ + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("dropdb", host=host, password=admin_pass, port=port, user=admin_user) + base.append(name) + + return Command(" ".join(base), **kwargs) + + +def pg_drop_user(name, admin_pass=None, admin_user="postgres", host="localhost", port=5432, **kwargs): + """Remove a Postgres user. + + :param name: The user name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + """ + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("dropuser", host=host, password=admin_pass, port=port, user=admin_user) + base.append(name) + + return Command(" ".join(base), **kwargs) + + +def pg_dump_database(name, admin_pass=None, admin_user="postgres", file_name=None, host="localhost", port=5432, + **kwargs): + """Export a Postgres database. + + :param name: The database name. + :type name: str + + :param admin_pass: The password for the user with sufficient access privileges to execute the command. + :type admin_pass: str + + :param admin_user: The name of the user with sufficient access privileges to execute the command. + :type admin_user: str + + :param host: The database host name or IP address. + :type host: str + + :param file_name: The name (including the path, if desired) of the export file. Defaults to the + ``database_name`` plus ".sql" + :type file_name: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + """ + _file_name = file_name or "%s.sql" % name + + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("pg_dump", host=host, password=admin_pass, port=port, user=admin_user) + base.append("--column-inserts") + base.append("--file=%s" % _file_name) + base.append(name) + + return Command(" ".join(base), **kwargs) + + +def psql(sql, database="template1", host="localhost", password=None, port=5432, user="postgres", **kwargs): + """Execute a psql command. + + :param sql: The SQL to be executed. + :type sql: str + + :param database: The database name. + :type database: str + + :param password: The password for the user with sufficient access privileges to execute the command. + :type password: str + + :param host: The database host name or IP address. + :type host: str + + :param port: The port number of the Postgres service running on the host. + :type port: int + + :param user: The name of the user with sufficient access privileges to execute the command. + :type user: str + + """ + # Postgres commands always run without sudo because the -U may be provided. + kwargs['sudo'] = False + + # Assemble the command. + base = _get_pgsql_command("psql", host=host, password=password, port=port, user=user) + base.append("--dbname=%s" % database) + base.append('-c "%s"' % sql) + + return Command(" ".join(base), **kwargs) + + +PGSQL_MAPPINGS = { + 'pg.client': psql, + 'pg.createdatabase': pg_create_database, + 'pg.createdb': pg_create_database, + 'pg.createuser': pg_create_user, + 'pg.database': pg_create_database, + 'pg.database_exists': pg_database_exists, + 'pg.db': pg_create_database, + 'pg.dropdatabase': pg_drop_database, + 'pg.dropdb': pg_drop_database, + 'pg.dropuser': pg_drop_user, + 'pg.dump': pg_dump_database, + 'pg.dumpdb': pg_dump_database, + 'pg.exists': pg_database_exists, + 'pg.user': pg_create_user, + 'psql': psql, +} diff --git a/scripttease/library/overlays/posix.py b/scripttease/library/overlays/posix.py index 3091c2d..f785b45 100644 --- a/scripttease/library/overlays/posix.py +++ b/scripttease/library/overlays/posix.py @@ -1,5 +1,172 @@ +# Imports + +import os +from superpython.utils import indent from ..commands import Command +# Exports + +__all__ = ( + "POSIX_MAPPINGS", + "archive", + "certbot", + "extract", + "file_append", + "file_copy", + "file_write", + "mkdir", + "move", + "perms", + "remove", + "rsync", + "run", + "scopy", + "sed", + "symlink", + "touch", + "Function", +) + +# Functions + + +def archive(from_path, absolute=False, exclude=None, file_name="archive.tgz", strip=None, to_path=".", view=False, + **kwargs): + """Create a file archive. + + :param from_path: The path that should be archived. + :type from_path: str + + :param absolute: By default, the leading slash is stripped from each path. Set to ``True`` to preserve the + absolute path. + :type absolute: bool + + :param bzip2: Compress using bzip2. + :type bzip2: bool + + :param exclude: A pattern to be excluded from the archive. + :type exclude: str + + :param format: The command to use for the operation. + :type format: str + + :param gzip: Compress using gzip. + :type gzip: bool + + :param strip: Remove the specified number of leading elements from the path. Paths with fewer elements will be + silently skipped. + :type strip: int + + :param to_path: Where the archive should be created. This should *not* include the file name. + :type to_path: str + + :param view: View the output of the command as it happens. + :type view: bool + + """ + tokens = ["tar"] + switches = ["-cz"] + + if absolute: + switches.append("P") + + if view: + switches.append("v") + + tokens.append("".join(switches)) + + if exclude: + tokens.append("--exclude %s" % exclude) + + if strip: + tokens.append("--strip-components %s" % strip) + + to_path = os.path.join(to_path, file_name) + tokens.append('-f %s %s' % (to_path, from_path)) + + name = " ".join(tokens) + + return Command(name, **kwargs) + + +def certbot(domain_name, email=None, webroot=None, **kwargs): + """Get new SSL certificate from Let's Encrypt. + + :param domain_name: The domain name for which the SSL certificate is requested. + :type domain_name: str + + :param email: The email address of the requester sent to the certificate authority. Required. + :type email: str + + :param webroot: The directory where the challenge file will be created. + :type webroot: str + + """ + _email = email or os.environ.get("SCRIPTTEASE_CERTBOT_EMAIL", None) + _webroot = webroot or os.path.join("/var", "www", "domains", domain_name.replace(".", "_"), "www") + + if not _email: + raise ValueError("Email is required for certbot command.") + + template = "certbot certonly --agree-tos --email %(email)s -n --webroot -w %(webroot)s -d %(domain_name)s" + name = template % { + 'domain_name': domain_name, + 'email': _email, + 'webroot': _webroot, + } + + return Command(name, **kwargs) + + +def extract(from_path, absolute=False, exclude=None, strip=None, to_path=None, view=False, **kwargs): + """Extract a file archive. + + :param from_path: The path to the archive file. + :type from_path: str + + :param absolute: By default, the leading slash is stripped from each path. Set to ``True`` to preserve the + absolute path. + :type absolute: bool + + :param exclude: A pattern to be excluded from the archive. + :type exclude: str + + :param strip: Remove the specified number of leading elements from the path. Paths with fewer elements will be + silently skipped. + :type strip: int + + :param to_path: Where the archive should be extracted. + :type to_path: str + + :param view: View the output of the command as it happens. + :type view: bool + + """ + _to_path = to_path or "./" + + tokens = ["tar"] + switches = ["-xz"] + + if absolute: + switches.append("P") + + if view: + switches.append("v") + + tokens.append("".join(switches)) + + if exclude: + tokens.append("--exclude %s" % exclude) + + if strip: + tokens.append("--strip-components %s" % strip) + + tokens.append('-f %s %s' % (from_path, _to_path)) + + name = " ".join(tokens) + + return Command(name, **kwargs) + def file_append(path, content=None, **kwargs): """Append content to a file. @@ -11,7 +178,6 @@ def file_append(path, content=None, **kwargs): :type content: str """ - print("HERE") kwargs.setdefault("comment", "append to %s" % path) statement = 'echo "%s" >> %s' % (content or "", path) @@ -52,6 +218,32 @@ def file_copy(from_path, to_path, overwrite=False, recursive=False, **kwargs): return Command(" ".join(a), **kwargs) +def file_write(path, content=None, **kwargs): + """Initialize the command. + + :param path: The file to be written. + :type path: str + + :param content: The content to be written. Note: If omitted, this command is equivalent to :py:class:`Touch`. + :type content: str + + """ + _content = content or "" + + kwargs.setdefault("comment", "write to %s" % path) + + a = list() + + if len(_content.split("\n")) > 1: + a.append("cat > %s << EOF" % path) + a.append(_content) + a.append("EOF") + else: + a.append('echo "%s" > %s' % (_content, path)) + + return Command(" ".join(a), **kwargs) + + def mkdir(path, mode=None, recursive=True, **kwargs): """Initialize the command. @@ -59,7 +251,7 @@ def mkdir(path, mode=None, recursive=True, **kwargs): :type path: str :param mode: The access permissions of the new directory. - :type mode: str + :type mode: int | str :param recursive: Create all directories along the path. :type recursive: bool @@ -96,7 +288,7 @@ def perms(path, group=None, mode=None, owner=None, recursive=False, **kwargs): :type group: str :param mode: The access permissions of the file or directory. - :type mode: str + :type mode: int | str :param owner: The name of the user to be applied. :type owner: str @@ -107,6 +299,8 @@ def perms(path, group=None, mode=None, owner=None, recursive=False, **kwargs): """ commands = list() + kwargs['comment'] = "set permissions on %s" % path + if group is not None: statement = ["chgrp"] @@ -144,7 +338,7 @@ def perms(path, group=None, mode=None, owner=None, recursive=False, **kwargs): a = list() for c in commands: - a.append(c.get_statement()) + a.append(c.get_statement(suppress_comment=True)) return Command("\n".join(a), **kwargs) @@ -175,3 +369,276 @@ def remove(path, force=False, recursive=False, **kwargs): statement.append(path) return Command(" ".join(statement), **kwargs) + + +def rsync(source, target, delete=False, exclude=None, host=None, key_file=None, links=True, port=22, + recursive=True, user=None, **kwargs): + """Initialize the command. + + :param source: The source directory. + :type source: str + + :param target: The target directory. + :type target: str + + :param delete: Indicates target files that exist in source but not in target should be removed. + :type delete: bool + + :param exclude: The path to an exclude file. + :type exclude: str + + :param host: The host name or IP address. This causes the command to run over SSH and may require a + ``key_file``, ``port``, and ``user``. + :type host: str + + :param key_file: The path to the private SSH key to use for remove connections. User expansion is + automatically applied. + :type key_file: str + + :param links: Include symlinks in the sync. + :type links: bool + + :param port: The SSH port to use for remote connections. + :type port: int + + :param recursive: Indicates source contents should be recursively synchronized. + :type recursive: bool + + :param user: The user name to use for remote connections. + + """ + # :param guess: When ``True``, the ``host``, ``key_file``, and ``user`` will be guessed based on the base name of + # the source path. + # :type guess: bool + # if guess: + # host = host or os.path.basename(source).replace("_", ".") + # key_file = key_file or os.path.expanduser(os.path.join("~/.ssh", os.path.basename(source))) + # user = user or os.path.basename(source) + # else: + # host = host + # key_file = key_file + # user = user + + kwargs.setdefault("comment", "copy %s to remote %s" % (source, target)) + + # rsync -e "ssh -i $(SSH_KEY) -p $(SSH_PORT)" -P -rvzc --delete + # $(OUTPUTH_PATH) $(SSH_USER)@$(SSH_HOST):$(UPLOAD_PATH) --cvs-exclude; + + tokens = list() + tokens.append('rsync') + tokens.append("--cvs-exclude") + tokens.append("--checksum") + tokens.append("--compress") + + if links: + tokens.append("--copy-links") + + if delete: + tokens.append("--delete") + + if exclude is not None: + tokens.append("--exclude-from=%s" % exclude) + + # --partial and --progress + tokens.append("-P") + + if recursive: + tokens.append("--recursive") + + tokens.append(source) + + conditions = [ + host is not None, + key_file is not None, + user is not None, + ] + if all(conditions): + tokens.append('-e "ssh -i %s -p %s"' % (key_file, port)) + tokens.append("%s@%s:%s" % (user, host, target)) + else: + tokens.append(target) + + statement = " ".join(tokens) + + return Command(statement, **kwargs) + + +def run(statement, **kwargs): + """Run any statement.""" + kwargs.setdefault("comment", "run statement") + return Command(statement, **kwargs) + + +def scopy(from_path, to_path, host=None, key_file=None, port=22, user=None, **kwargs): + """Initialize the command. + + :param from_path: The source directory. + :type from_path: str + + :param to_path: The target directory. + :type to_path: str + + :param host: The host name or IP address. Required. + :type host: str + + :param key_file: The path to the private SSH key to use for remove connections. User expansion is + automatically applied. + :type key_file: str + + :param port: The SSH port to use for remote connections. + :type port: int + + :param user: The user name to use for remote connections. + + """ + kwargs.setdefault("comment", "copy %s to remote %s" % (from_path, to_path)) + + # TODO: What to do to force local versus remote commands? + # kwargs['local'] = True + + kwargs['sudo'] = False + + statement = ["scp"] + + if key_file is not None: + statement.append("-i %s" % key_file) + + statement.append("-P %s" % port) + statement.append(from_path) + + if host is not None and user is not None: + statement.append("%s@%s:%s" % (user, host, to_path)) + elif host is not None: + statement.append("%s:%s" % (host, to_path)) + else: + raise ValueError("Host is a required keyword argument.") + + return Command(" ".join(statement), **kwargs) + + +def sed(path, backup=".b", delimiter="/", find=None, replace=None, **kwargs): + """Find and replace text in a file. + + :param path: The path to the file to be edited. + :type path: str + + :param backup: The backup file extension to use. + :type backup: str + + :param delimiter: The pattern delimiter. + + :param find: The old text. Required. + :type find: str + + :param replace: The new text. Required. + :type replace: str + + """ + + kwargs.setdefault("comment", "find and replace in %s" % path) + + context = { + 'backup': backup, + 'delimiter': delimiter, + 'path': path, + 'pattern': find, + 'replace': replace, + } + + template = "sed -i %(backup)s 's%(delimiter)s%(pattern)s%(delimiter)s%(replace)s%(delimiter)sg' %(path)s" + + statement = template % context + + return Command(statement, **kwargs) + + +def symlink(source, force=False, target=None, **kwargs): + """Initialize the command. + + :param source: The source of the link. + :type source: str + + :param force: Force the creation of the link. + :type force: bool + + :param target: The name or path of the target. Defaults to the base name of the source path. + :type target: str + + """ + _target = target or os.path.basename(source) + + kwargs.setdefault("comment", "link to %s" % source) + + statement = ["ln -s"] + + if force: + statement.append("-f") + + statement.append(source) + statement.append(_target) + + return Command(" ".join(statement), **kwargs) + + +def touch(path, **kwargs): + """Initialize the command. + + :param path: The file or directory to touch. + :type path: str + + """ + kwargs.setdefault("comment", "touch %s" % path) + + return Command("touch %s" % path, **kwargs) + +# Classes + + +class Function(object): + """A function that may be used to organize related commands to be called together.""" + + def __init__(self, name, commands=None, comment=None): + self.commands = commands or list() + self.comment = comment + self.name = name + + def to_string(self): + a = list() + + if self.comment is not None: + a.append("# %s" % self.comment) + + a.append("function %s()" % self.name) + a.append("{") + for command in self.commands: + a.append(indent(command.get_statement(cd=True))) + a.append("") + + a.append("}") + + return "\n".join(a) + +# Mappings + + +POSIX_MAPPINGS = { + 'append': file_append, + 'archive': archive, + 'certbot': certbot, + 'copy': file_copy, + 'extract': extract, + 'func': Function, + 'function': Function, + 'mkdir': mkdir, + 'move': move, + 'perms': perms, + 'remove': remove, + 'rsync': rsync, + 'run': run, + 'scopy': scopy, + 'sed': sed, + 'ssl': certbot, + 'symlink': symlink, + 'touch': touch, + 'write': file_write, +} diff --git a/scripttease/library/overlays/ubuntu.py b/scripttease/library/overlays/ubuntu.py index c3e96c6..aa9a3d1 100644 --- a/scripttease/library/overlays/ubuntu.py +++ b/scripttease/library/overlays/ubuntu.py @@ -1,14 +1,59 @@ -from ..commands import Command -from .common import python_pip, python_virtualenv -from .posix import file_append, file_copy, mkdir, move, perms, remove +# Imports -name = "ubuntu" +from ..commands import Command +from .common import COMMON_MAPPINGS +from .django import DJANGO_MAPPINGS +from .pgsql import PGSQL_MAPPINGS +from .posix import POSIX_MAPPINGS, Function + +# Exports + +__all__ = ( + "MAPPINGS", + "apache", + "apache_disable_module", + "apache_disable_site", + "apache_enable_module", + "apache_enable_site", + "apache_reload", + "apache_restart", + "apache_start", + "apache_stop", + "apache_test", + "command_exists", + "service_reload", + "service_restart", + "service_start", + "service_stop", + "system", + "system_install", + "system_reboot", + "system_update", + "system_upgrade", + "system_uninstall", + "Function", +) def command_exists(name): return name in MAPPINGS +def apache(op, **kwargs): + if op == "reload": + return apache_reload(**kwargs) + elif op == "restart": + return apache_restart(**kwargs) + elif op == "start": + return apache_start(**kwargs) + elif op == "stop": + return apache_stop(**kwargs) + elif op == "test": + return apache_test(**kwargs) + else: + raise NameError("Unrecognized or unsupported apache operation: %s" % op) + + def apache_disable_module(name, **kwargs): kwargs.setdefault("comment", "disable %s apache module" % name) @@ -30,7 +75,7 @@ def apache_enable_module(name, **kwargs): def apache_enable_site(name, **kwargs): kwargs.setdefault("comment", "enable %s apache module" % name) - return Command("a2densite %s" % name, **kwargs) + return Command("a2ensite %s" % name, **kwargs) def apache_reload(**kwargs): @@ -78,7 +123,7 @@ def service_restart(name, **kwargs): kwargs.setdefault("comment", "restart %s service" % name) kwargs.setdefault("register", "%s_restarted" % name) - return Command("service %s reload" % name, **kwargs) + return Command("service %s restart" % name, **kwargs) def service_start(name, **kwargs): @@ -95,6 +140,17 @@ def service_stop(name, **kwargs): return Command("service %s stop" % name, **kwargs) +def system(op, **kwargs): + if op == "reboot": + return system_reboot(**kwargs) + elif op == "update": + return system_update(**kwargs) + elif op == "upgrade": + return system_upgrade(**kwargs) + else: + raise NameError("Unrecognized or unsupported system operation: %s" % op) + + def system_install(name, **kwargs): kwargs.setdefault("comment", "install system package %s" % name) @@ -126,6 +182,7 @@ def system_upgrade(**kwargs): MAPPINGS = { + 'apache': apache, 'apache.disable_module': apache_disable_module, 'apache.disable_site': apache_disable_site, 'apache.enable_module': apache_enable_module, @@ -135,21 +192,19 @@ MAPPINGS = { 'apache.start': apache_start, 'apache.stop': apache_stop, 'apache.test': apache_test, - 'append': file_append, - 'copy': file_copy, 'install': system_install, - 'mkdir': mkdir, - 'move': move, - 'perms': perms, - 'pip': python_pip, 'reboot': system_reboot, 'reload': service_reload, - 'remove': remove, 'restart': service_restart, 'start': service_start, 'stop': service_stop, + 'system': system, 'update': system_update, 'uninstall': system_uninstall, 'upgrade': system_upgrade, - 'virtualenv': python_virtualenv, } + +MAPPINGS.update(COMMON_MAPPINGS) +MAPPINGS.update(DJANGO_MAPPINGS) +MAPPINGS.update(PGSQL_MAPPINGS) +MAPPINGS.update(POSIX_MAPPINGS) diff --git a/scripttease/library/scripts.py b/scripttease/library/scripts.py index 8849105..44734fc 100644 --- a/scripttease/library/scripts.py +++ b/scripttease/library/scripts.py @@ -11,7 +11,7 @@ class Script(object): :type name: str :param commands: The commands to be included. - :type commands: list[BaseType[Command]] + :type commands: list[scripttease.library.commands.base.Command] :param functions: The functions to be included. :type functions: list[Function] @@ -49,21 +49,21 @@ class Script(object): a = list() if shebang is not None: - a.append("%s" % {'shell': self.shell}) + a.append(shebang % {'shell': self.shell}) a.append("") if self.functions is not None: for function in self.functions: - a.append(function.preview()) + a.append(function.to_string()) a.append("") - for function in self.functions: - a.append("%s;" % function.name) + # for function in self.functions: + # a.append("%s;" % function.name) a.append("") for command in self.commands: - a.append(command.preview(cwd=True)) + a.append(command.get_statement(cd=True)) a.append("") return "\n".join(a) diff --git a/scripttease/parsers/__init__.py b/scripttease/parsers/__init__.py index 1976ae9..c49ecac 100644 --- a/scripttease/parsers/__init__.py +++ b/scripttease/parsers/__init__.py @@ -1,85 +1,4 @@ # Imports -import logging -from superpython.utils import any_list_item -from ..constants import LOGGER_NAME from .ini import Config - -log = logging.getLogger(LOGGER_NAME) - -# Exports - -__all__ = ( - "filter_commands", - "load_commands", -) - -# Functions - - -def filter_commands(commands, environments=None, tags=None): - """Filter commands based on the given criteria. - - :param commands: The commands to be filtered. - :type commands: list - - :param environments: Environment names to be matched. - :type environments: list[str] - - :param tags: Tag names to be matched. - :type tags - :return: - """ - filtered = list() - for command in commands: - if environments is not None: - if not any_list_item(environments, command.environments): - continue - - if tags is not None: - if not any_list_item(tags, command.tags): - continue - - filtered.append(command) - - return filtered - - -def load_commands(path, filters=None, overlay=None, **kwargs): - """Load commands from a configuration file. - - :param path: The path to the configuration file. - :type path: str - - :param filters: Used to filter commands. - :type filters: dict - - :rtype: list[BaseType[Command] | ItemizedCommand] | None - - :returns: A list of command instances or ``None`` if the configuration could not be loaded. - - kwargs are passed to the configuration class for instantiation. - - """ - if path.endswith(".ini"): - _config = Config(path, overlay=overlay, **kwargs) - # elif path.endswith(".yml"): - # _config = YAML(path, **kwargs) - else: - log.warning("Input file format is not currently supported: %s" % path) - return None - - if _config.load(): - commands = _config.get_commands() - - if filters is not None: - criteria = dict() - for attribute, values in filters.items(): - criteria[attribute] = values - - commands = filter_commands(commands, **criteria) - - return commands - - log.error("Failed to load config file: %s" % path) - return None +from .utils import filter_commands, load_commands, load_config diff --git a/scripttease/parsers/base.py b/scripttease/parsers/base.py index ca3c2d9..7e9753d 100644 --- a/scripttease/parsers/base.py +++ b/scripttease/parsers/base.py @@ -1,13 +1,13 @@ # Imports from superpython.utils import File -from ..library.overlays import Overlay +from ..factory import Factory from ..library.scripts import Script # Exports __all__ = ( - "Parser" + "Parser", ) # Classes @@ -16,19 +16,18 @@ __all__ = ( class Parser(File): """Base class for implementing a command parser.""" - def __init__(self, path, context=None, locations=None, options=None, overlay=None): + def __init__(self, path, context=None, locations=None, options=None, overlay="ubuntu"): super().__init__(path) self.context = context + self.factory = Factory(overlay) self.is_loaded = False self.locations = locations or list() self.options = options or dict() - self.overlay = overlay or Overlay("ubuntu") + self.overlay = overlay self._commands = list() self._functions = list() - self.overlay.load() - def as_script(self): """Convert loaded commands to a script. @@ -73,4 +72,9 @@ class Parser(File): return a def load(self): + """Load the factory and the configuration file. + + :rtype: bool + + """ raise NotImplementedError() diff --git a/scripttease/parsers/ini.py b/scripttease/parsers/ini.py index 690762c..f69ea78 100644 --- a/scripttease/parsers/ini.py +++ b/scripttease/parsers/ini.py @@ -4,7 +4,6 @@ from configparser import ConfigParser, ParsingError import logging from superpython.utils import parse_jinja_template, read_file, smart_cast, split_csv import os -from ..library.commands import command_factory from ..constants import LOGGER_NAME from .base import Parser @@ -23,9 +22,13 @@ class Config(Parser): """An INI configuration for loading commands.""" def load(self): + """Load commands from a INI file.""" if not self.exists: return False + if not self.factory.load(): + return False + ini = self._load_ini() if ini is None: return False @@ -36,12 +39,15 @@ class Config(Parser): command_name = None count = 0 kwargs = self.options.copy() + kwargs['comment'] = comment for key, value in ini.items(comment): # The first key/value pair is the command name and arguments. if count == 0: command_name = key + # Arguments surrounded by quotes are considered to be one argument. All others are split into a + # list to be passed to the callback. if value[0] == '"': args.append(value.replace('"', "")) else: @@ -53,8 +59,13 @@ class Config(Parser): count += 1 - command = command_factory(command_name, comment, self.overlay, *args, **kwargs) + command = self.factory.get_command(command_name, *args, **kwargs) if command is not None: + if isinstance(command, self.factory.overlay.Function): + self._functions.append(command) + else: + self._commands.append(command) + # if isinstance(command, Function): # self._functions.append(command) # elif isinstance(command, Include): @@ -70,7 +81,6 @@ class Config(Parser): # self._commands.append(c) # else: # self._commands.append(command) - self._commands.append(command) else: success = False @@ -131,20 +141,20 @@ class Config(Parser): log.error("Failed to parse %s: %s" % (self.path, e)) return None - def _load_template(self, command): - """Load additional resources for a template command. - - :param command: The template command. - :type command: Template - - """ - # This may produce problems if template kwargs are the same as the given context. - if self.context is not None: - command.context.update(self.context) - - # Custom locations come before default locations. - command.locations += self.locations - - # This allows template files to be specified relative to the configuration file. - command.locations.append(os.path.join(self.directory, "templates")) - command.locations.append(self.directory) + # def _load_template(self, command): + # """Load additional resources for a template command. + # + # :param command: The template command. + # :type command: Template + # + # """ + # # This may produce problems if template kwargs are the same as the given context. + # if self.context is not None: + # command.context.update(self.context) + # + # # Custom locations come before default locations. + # command.locations += self.locations + # + # # This allows template files to be specified relative to the configuration file. + # command.locations.append(os.path.join(self.directory, "templates")) + # command.locations.append(self.directory) diff --git a/scripttease/parsers/utils.py b/scripttease/parsers/utils.py new file mode 100644 index 0000000..d92f29f --- /dev/null +++ b/scripttease/parsers/utils.py @@ -0,0 +1,110 @@ +# Imports + +import logging +from superpython.utils import any_list_item +from ..constants import LOGGER_NAME +from .ini import Config + +log = logging.getLogger(LOGGER_NAME) + +# Exports + +__all__ = ( + "filter_commands", + "load_commands", +) + +# Functions + + +def filter_commands(commands, environments=None, tags=None): + """Filter commands based on the given criteria. + + :param commands: The commands to be filtered. + :type commands: list + + :param environments: Environment names to be matched. + :type environments: list[str] + + :param tags: Tag names to be matched. + :type tags + :return: + """ + filtered = list() + for command in commands: + if environments is not None: + if not any_list_item(environments, command.environments): + continue + + if tags is not None: + if not any_list_item(tags, command.tags): + continue + + filtered.append(command) + + return filtered + + +def load_commands(path, filters=None, overlay="ubuntu", **kwargs): + """Load commands from a configuration file. + + :param path: The path to the configuration file. + :type path: str + + :param filters: Used to filter commands. + :type filters: dict + + :param overlay: The name of the command overlay to apply to generated commands. + :type overlay: str + + :rtype: list[scriptetease.library.commands.base.Command] | scriptetease.library.commands.base.ItemizedCommand] | + None + + :returns: A list of command instances or ``None`` if the configuration could not be loaded. + + kwargs are passed to the configuration class for instantiation. + + """ + _config = load_config(path, overlay, **kwargs) + if _config is None: + return None + + commands = _config.get_commands() + + if filters is not None: + criteria = dict() + for attribute, values in filters.items(): + criteria[attribute] = values + + commands = filter_commands(commands, **criteria) + + return commands + + +def load_config(path, overlay="ubuntu", **kwargs): + """Load a command configuration. + + :param path: The path to the configuration file. + :type path: str + + :param overlay: The name of the command overlay to apply to generated commands. + :type overlay: str + + :rtype: Config | None + + kwargs are passed to the configuration class for instantiation. + + """ + if path.endswith(".ini"): + _config = Config(path, overlay=overlay, **kwargs) + # elif path.endswith(".yml"): + # _config = YAML(path, **kwargs) + else: + log.warning("Input file format is not currently supported: %s" % path) + return None + + if not _config.load(): + log.error("Failed to load config file: %s" % path) + return None + + return _config diff --git a/scripttease/utils.py b/scripttease/utils.py deleted file mode 100644 index 23068f7..0000000 --- a/scripttease/utils.py +++ /dev/null @@ -1,159 +0,0 @@ -# Imports - -from pygments import highlight -from pygments.lexers import get_lexer_by_name -from pygments.formatters import get_formatter_by_name - -BashLexer = get_lexer_by_name("bash") -JSONLexer = get_lexer_by_name("json") -PythonLexer = get_lexer_by_name("python") -TerminalFormatter = get_formatter_by_name("terminal", linenos=True) - -# Exports - -__all__ = ( - "any_list_item", - "filter_commands", - "filter_objects", - "highlight_code", - "split_csv", -) - -# Functions - - -def any_list_item(a, b): - """Determine whether any item in ``a`` also exists in ``b``. - - :param a: The first list to be compared. - :type a: list - - :param b: The second list to be compared. - :type b: list - - :rtype: bool - - """ - for i in a: - for j in b: - if i == j: - return True - - return False - - -def filter_commands(commands, values, attribute="tags"): - """Filter commands for a given set of values. - - :param commands: The commands to be filtered. - :type commands: list[BaseType[Command]] - - :param values: The values to be compared. - :type values: list - - :param attribute: The name of the command attribute to check. This attribute must be a list or tuple of values of - the same type given in ``values``. - :type attribute: str - - :rtype: bool - - .. code-block:: python - - commands = [ - AddUser("bob"), - Apt("apache2", tags=["apache", "www"]), - Reload("postgresql", tags=["database", "pgsql"]), - Touch("/var/www/index.html", tags=["www"]), - ] - - values = ["apache", "www"] - - # Outputs the Apt and Touch commands above. - filtered_commands = filter_commands(command, values) - print(filtered_commands) - - """ - filtered = list() - for command in commands: - try: - list_b = getattr(command, attribute) - except AttributeError: - continue - - if not any_list_item(values, list_b): - continue - - filtered.append(command) - - return filtered - - -def filter_objects(objects, environments=None, scope=None, tags=None): - """Filter the given objects by the given keys. - - :param objects: The objects to be filtered. - :type objects: list - - :param environments: The environments to be included. - :type environments: list[str] - - :param scope: The scope by which to filter; deploy, provision, tenant. - :type scope: str - - :param tags: The tags to be included. - :type tags: list[str] - - :rtype: list - :returns: Returns the objects that match the given keys. - - """ - filtered = list() - - # print("object, object environments, environments, any_list_item") - - for o in objects: - - # print(o, o.environments, environments, any_list_item(environments, o.environments)) - - # Apply environment filter. - if environments is not None: - if hasattr(o, "environment"): - if o.environment is not None and o.environment not in environments: - continue - elif hasattr(o, "environments"): - if type(o.environments) in (list, tuple) and not any_list_item(environments, o.environments): - continue - else: - pass - - # # Apply scope filter. - # if scope is not None: - # if o.scope not in [None, SCOPE_ALL, scope]: - # continue - - # Apply tag filter. - if tags is not None: - if not any_list_item(tags, o.tags): - continue - - # The object has passed the tests above. - filtered.append(o) - - return filtered - - -def highlight_code(string, lexer=None): - """Highlight (colorize) the given string as Python code. - - :param string: The string to be highlighted. - :type string: str - - :rtype: str - - """ - if lexer is None: - lexer = BashLexer - - return highlight(string, lexer, TerminalFormatter) - - diff --git a/setup.py b/setup.py index 9701f3b..8e08f50 100644 --- a/setup.py +++ b/setup.py @@ -12,22 +12,22 @@ def read_file(path): setup( - name='python-script-tease', + name='python-scripttease', version=read_file("VERSION.txt"), description=read_file("DESCRIPTION.txt"), long_description=read_file("README.markdown"), author='Shawn Davis', - author_email='shawn@myninjas.net', - url='https://bitbucket.com/myninjas/python-script-tease', + author_email='shawn@develmaycare.com', + url='https://github.com/develmaycare/python-scripttease', packages=find_packages(), include_package_data=True, install_requires=[ "jinja2", "pygments", - "python-myninjas", + "superpython", ], dependency_links=[ - "https://bitbucket.com/myninjas/python-myninjas/master.tar.gz#python-myninjas", + "https://github.com/develmaycare/superpython", ], classifiers=[ 'Development Status :: 2 - Pre-Alpha', diff --git a/tests/examples/apache_examples.ini b/tests/examples/apache_examples.ini index f8125cb..d41ff25 100644 --- a/tests/examples/apache_examples.ini +++ b/tests/examples/apache_examples.ini @@ -15,15 +15,3 @@ apache.enable_module = ssl [atlernative syntax disable the default site] apache = site default state = disabled - - -[apache] -disable_module = a2dismod {{ module_name }} -disable_site = a2dissite {{ domain_name }}.conf -enable_module = a2enmod {{ module_name }} -enable_site = a2ensite {{ domain_name }}.conf -reload = service apache2 reload -restart = service apache2 restart -start = service apache2 start -stop = service apache2 stop -test = apachectl configtest \ No newline at end of file diff --git a/tests/examples/bad_command.ini b/tests/examples/bad_command.ini new file mode 100644 index 0000000..0191233 --- /dev/null +++ b/tests/examples/bad_command.ini @@ -0,0 +1,2 @@ +[this command will fail to load] +nonexistent = testing \ No newline at end of file diff --git a/tests/examples/bad_examples.ini b/tests/examples/bad_examples.ini new file mode 100644 index 0000000..c4827fe --- /dev/null +++ b/tests/examples/bad_examples.ini @@ -0,0 +1,2 @@ +[this command will fail to load] +run - testing \ No newline at end of file diff --git a/tests/examples/bad_template_example.ini b/tests/examples/bad_template_example.ini new file mode 100644 index 0000000..3167cb6 --- /dev/null +++ b/tests/examples/bad_template_example.ini @@ -0,0 +1,6 @@ +[create the site directory] +mkdir: /var/www/domains/{{ domain_tld }} + +[this will cause template parsing to fail] +touch: /path/to/{% if this.will.break %}testing + diff --git a/tests/examples/function_examples.ini b/tests/examples/function_examples.ini new file mode 100644 index 0000000..a848b01 --- /dev/null +++ b/tests/examples/function_examples.ini @@ -0,0 +1,25 @@ +[function for setting up apache] +func = apache_setup + +[install apache] +install: apache2 +func: apache_setup + +[install wsgi] +install: mod_wsgi +func: apache_setup + +[enable wsgi] +apache.enable_module: mod_wsgi +func: apache_setup + +[disable the default site] +apache.disable_site = default +func: apache_setup + +[restart apache] +apache: restart +func: apache_setup + +[call apache setup] +run: apache_setup diff --git a/tests/examples/kitchen_sink.ini b/tests/examples/kitchen_sink.ini new file mode 100644 index 0000000..6f28335 --- /dev/null +++ b/tests/examples/kitchen_sink.ini @@ -0,0 +1,192 @@ +[update system repos] +system: update + +[upgrade the system] +system: upgrade + +[reboot the system] +system: reboot + +[function for setting up apache] +func = apache_setup + +[install apache] +install: apache2 +func: apache_setup + +[install wsgi] +install: mod_wsgi +func: apache_setup + +[enable wsgi] +apache.enable_module: mod_wsgi +func: apache_setup + +[disable the default site in function] +apache.disable_site = default +func: apache_setup + +[restart apache] +apache: restart +func: apache_setup + +[call apache setup] +run: apache_setup + +[disable the default site] +apache.disable_site = default + +[enable mod SSL] +apache.enable_module = mod_ssl + +[enable more than one apache module at once] +apache.enable_module = $item +items = mod_wsgi, mod_rewrite + +[make sure apache can be reloaded] +apache: test + +[restart apache outside of function] +apache: restart +condition: $apache_checks_out -eq 0 + +[install the virtualenv package] +pip = virtualenv + +[install django debug toolbar] +pip: django-debug-toolbar +env: development +tags: python, project + +[create a virtual environment] +virtualenv = python +cd = /path/to/project +tags = python, project + +[install pillow] +pip = Pillow +cd = /path/to/project +upgrade = yes +venv = python +tags = python, project + +[apply database migrations] +django: migrate +cd: /path/to/project +venv: python + +[run a custom django command] +django = custom_command arg1 arg2 +cd = /path/to/project +venv = python +settings = tenants.example_app.settings +quiet = yes + +[collect the project's static files] +django: collectstatic +cd: /path/to/project +venv: python + +[load data fixtures] +django.loaddata: categories +cd: /path/to/project +venv: python + +[dump data fixtures] +django.dumpdata: projects +cd: /path/to/project +venv: python + +[reload a service] +reload: postfix + +[restart a service] +restart: postfix + +[stop a service] +stop: postfix + +[start a service] +start: postfix + +[install a package] +install: python3 + +[remove a package] +uninstall: apache-top + +[add to a file] +append: /path/to/file.txt +content: this is a test + +[copy a file] +copy: /path/to/file.txt /new/path/to/file.txt + +[write (overwrite) a file] +write: /path/to/file.txt +content: this replaces all text in the file + +[create a directory] +mkdir: /path/to/dir +mode: 755 + +[move a file] +move: /path/to/file.txt /path/to/file.txt.b + +[set permissions on a file] +perms: /path/to/file.txt +group: www-data +mode: 755 +owner: deploy +recursive: yes + +[remove a file] +remove: /path/to/file.txt + +[sync a directory] +rsync: /path/to/source /path/to/target + +[copy a file to remote server] +scopy: /path/to/file.txt /path/to/server/file.txt +host: example.com + +[replace text in a file] +sed: /path/to/file.txt +find: logging = no +replace: logging = yes + +[create a symlink] +symlink: /var/www/domains + +[touch a file] +touch: /path/to/file.txt + +[create a postgres user/role] +pg.user: example_app + +[create a postgres database] +pg.db: example_app +owner: example_app + +[determine whether a postgres database exists] +pg.database_exists: example_app + +[export a postgres database] +pg.dump: testing + +[drop a postgres user/role] +pg.dropuser: testing + +[drop a postgres database] +pg.dropdb: testing + +[run an SQL command on a postgres database] +psql: "SELECT * FROM projects WHERE category = 'testing'" +database: example_app +owner: example_app + +[create a file archive] +archive: /var/www/domains/example_com + +[extract a file archive] +extract: /var/www/domains/example_com.tgz diff --git a/tests/examples/python_examples.ini b/tests/examples/python_examples.ini index 314e693..a35a966 100644 --- a/tests/examples/python_examples.ini +++ b/tests/examples/python_examples.ini @@ -1,12 +1,15 @@ [install the virtualenv package] pip = virtualenv +tags = python-support [create a virtual environment] virtualenv = python cd = /path/to/project +tags = python-support [install pillow] pip = Pillow cd = /path/to/project upgrade = yes venv = python +tags = depends \ No newline at end of file diff --git a/tests/examples/template_example.ini b/tests/examples/template_example.ini new file mode 100644 index 0000000..1d92aec --- /dev/null +++ b/tests/examples/template_example.ini @@ -0,0 +1,2 @@ +[create the site directory] +mkdir: /var/www/domains/{{ domain_tld }} diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..63002f2 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,40 @@ +from scripttease.library.commands import Command, ItemizedCommand +from scripttease.factory import Factory + + +class TestFactory(object): + + def test_get_command(self): + f = Factory("ubuntu") + f.load() + + # Non-existent command. + c = f.get_command("nonexistent") + assert c is None + + # A good command with itemized parameters. + c = f.get_command( + "pip", + "$item", + items=["Pillow", "psycopg2-binary", "django"] + ) + assert isinstance(c, ItemizedCommand) + + # A good, normal command. + c = f.get_command("pip", "django") + assert isinstance(c, Command) + + # Command exists, but given bad arguments. + c = f.get_command("pip") + assert c is None + + def test_load(self): + f = Factory("nonexistent") + assert f.load() is False + + f = Factory("ubuntu") + assert f.load() is True + + def test_repr(self): + f = Factory("centos") + assert repr(f) == "" diff --git a/tests/test_library_commands_base.py b/tests/test_library_commands_base.py index 4271f0d..115a73c 100644 --- a/tests/test_library_commands_base.py +++ b/tests/test_library_commands_base.py @@ -1,6 +1,5 @@ from scripttease.library.commands.base import Command, ItemizedCommand, Sudo -from scripttease.library.commands.python import Pip -from scripttease.library.overlays import Overlay +from scripttease.library.overlays.common import python_pip class TestCommand(object): @@ -65,22 +64,22 @@ class TestCommand(object): class TestItemizedCommand(object): def test_getattr(self): - c = ItemizedCommand(Pip, ["Pillow", "psycopg2-binary", "django"], "$item", extra=True) + c = ItemizedCommand(python_pip, ["Pillow", "psycopg2-binary", "django"], "$item", extra=True) assert c.extra is True def test_get_commands(self): - c = ItemizedCommand(Pip, ["Pillow", "psycopg2-binary", "django"], "$item") + c = ItemizedCommand(python_pip, ["Pillow", "psycopg2-binary", "django"], "$item") commands = c.get_commands() for i in commands: - assert isinstance(i, Pip) + assert isinstance(i, Command) def test_get_statement(self): - c = ItemizedCommand(Pip, ["Pillow", "psycopg2-binary", "django"], "$item") + c = ItemizedCommand(python_pip, ["Pillow", "psycopg2-binary", "django"], "$item") statement = c.get_statement() assert "Pillow" in statement assert "psycopg2-binary" in statement assert "django" in statement def test_repr(self): - c = ItemizedCommand(Pip, ["Pillow", "psycopg2-binary", "django"], "$item") - assert repr(c) == "" + c = ItemizedCommand(python_pip, ["Pillow", "psycopg2-binary", "django"], "$item") + assert repr(c) == "" diff --git a/tests/test_library_commands_factory.py b/tests/test_library_commands_factory.py deleted file mode 100644 index 4919f2d..0000000 --- a/tests/test_library_commands_factory.py +++ /dev/null @@ -1,20 +0,0 @@ -from scripttease.library.commands import command_factory, ItemizedCommand -from scripttease.library.commands.python import Pip -from scripttease.library.overlays import Overlay - - -def test_command_factory(): - overlay = Overlay("ubuntu") - overlay.load() - - command = command_factory("nonexistent", "non existent command", overlay) - assert command is None - - command = command_factory("pip", "install pillow", overlay) - assert command is None - - command = command_factory("pip", "install pillow", overlay, "Pillow") - assert isinstance(command, Pip) - - command = command_factory("pip", "install various", overlay, "$item", items=["Pillow", "pyscopg2-binary", "django"]) - assert isinstance(command, ItemizedCommand) diff --git a/tests/test_library_commands_python.py b/tests/test_library_commands_python.py deleted file mode 100644 index 18b89d2..0000000 --- a/tests/test_library_commands_python.py +++ /dev/null @@ -1,18 +0,0 @@ -from scripttease.library.commands.python import * -from scripttease.library.overlays import Overlay - - -def test_pip(): - pip = Pip("Pillow") - assert "pip install -y Pillow" in pip.get_statement() - - overlay = Overlay("ubuntu") - overlay.load() - - pip = Pip("Pillow", op="remove", overlay=overlay, venv="python") - assert "source python/bin/activate && pip3 uninstall --quiet Pillow" in pip.get_statement() - - -def test_virtualenv(): - virt = VirtualEnv() - assert "virtualenv python" in virt.get_statement() diff --git a/tests/test_library_overlays_common.py b/tests/test_library_overlays_common.py new file mode 100644 index 0000000..8c4b1b6 --- /dev/null +++ b/tests/test_library_overlays_common.py @@ -0,0 +1,17 @@ +from scripttease.library.overlays.common import * + + +def test_python_pip(): + c = python_pip("Pillow") + assert "pip install -y Pillow" in c.get_statement() + + c = python_pip("Pillow", upgrade=True) + assert "--upgrade" in c.get_statement() + + c = python_pip("Pillow", venv="python") + assert "source python/bin/activate" in c.get_statement() + + +def test_python_virtual_env(): + c = python_virtualenv() + assert "virtualenv python" in c.get_statement() diff --git a/tests/test_library_overlays_django.py b/tests/test_library_overlays_django.py new file mode 100644 index 0000000..a8c62ec --- /dev/null +++ b/tests/test_library_overlays_django.py @@ -0,0 +1,61 @@ +from scripttease.library.overlays.django import * + + +def test_django(): + c = django("check") + assert "./manage.py check" in c.get_statement() + + c = django("collectstatic") + assert "./manage.py collectstatic" in c.get_statement() + + c = django("migrate") + assert "./manage.py migrate" in c.get_statement() + + c = django("custom", "arg1", "arg2", venv="python", settings="tenants.example.settings", quiet=True) + s = c.get_statement() + assert "./manage.py custom" in s + assert "arg1" in s + assert "arg2" in s + assert "--settings=" in s + assert "source python/bin/activate" in s + assert "--quiet" in s + + +def test_django_check(): + c = django_check(venv="python") + s = c.get_statement() + assert "./manage.py check" in s + assert "source python/bin/activate" in s + + +def test_django_collect_static(): + c = django_collect_static(venv="python") + s = c.get_statement() + assert "./manage.py collectstatic" in s + assert "source python/bin/activate" in s + + +def test_django_dumpdata(): + c = django_dumpdata("projects") + s = c.get_statement() + assert "./manage.py dumpdata" in s + assert "projects >" in s + assert "--format=json" in s + assert "--indent=4" in s + assert "local/projects/fixtures/initial.json" in s + + +def test_django_loaddata(): + c = django_loaddata("projects") + s = c.get_statement() + print(s) + assert "./manage.py loaddata" in s + assert "local/projects/fixtures/initial.json" in s + + +def test_django_migrate(): + c = django_migrate(cd="/path/to/project/", venv="python") + s = c.get_statement(cd=True) + assert "./manage.py migrate" in s + assert "source python/bin/activate" in s + assert "cd /path/to/project/" in s diff --git a/tests/test_library_overlays_pgsql.py b/tests/test_library_overlays_pgsql.py new file mode 100644 index 0000000..b25566f --- /dev/null +++ b/tests/test_library_overlays_pgsql.py @@ -0,0 +1,60 @@ +from scripttease.library.overlays.pgsql import * + + +def test_pg_create_database(): + c = pg_create_database("testing", admin_pass="secret", template="mytemplate") + s = c.get_statement() + assert "createdb" in s + assert "export PGPASSWORD=" in s + assert "--host=" in s + assert "--port=" in s + assert "--username=" in s + assert "--owner=" in s + assert "--template=mytemplate" in s + assert "testing" in s + + +def test_pg_create_user(): + c = pg_create_user("testing", password="secret") + s = c.get_statement() + assert "createuser" in s + assert "-DRS" in s + assert "testing" in s + assert "ALTER USER testing" in s + + +def test_pg_database_exists(): + c = pg_database_exists("testing") + s = c.get_statement() + assert "psql" in s + assert "testing_db_exists" in s + + +def test_pg_drop_database(): + c = pg_drop_database("testing") + s = c.get_statement() + assert "dropdb" in s + assert "testing" in s + + +def test_pg_drop_user(): + c = pg_drop_user("testing") + s = c.get_statement() + assert "dropuser" in s + assert "testing" in s + + +def test_pg_dump_database(): + c = pg_dump_database("testing") + s = c.get_statement() + assert "pg_dump" in s + assert "--column-inserts" in s + assert "--file=testing.sql" in s + + +def test_psql(): + c = psql("SELECT * FROM projects", database="testing") + s = c.get_statement() + assert "psql" in s + assert "--dbname=testing" in s + assert '-c "SELECT * FROM projects"' in s diff --git a/tests/test_library_overlays_posix.py b/tests/test_library_overlays_posix.py new file mode 100644 index 0000000..2f0627e --- /dev/null +++ b/tests/test_library_overlays_posix.py @@ -0,0 +1,209 @@ +import pytest +from scripttease.library.overlays.posix import * + + +def test_archive(): + c = archive( + "/path/to/target", + absolute=True, + exclude="*.log", + strip=1, + view=True + ) + s = c.get_statement() + print(s) + # tar -czPv --exclude *.log --strip-components 1 -f ./archive.tgz /path/to/target + assert "tar -czPv --exclude *.log --strip-components 1" in s + assert "-f ./archive.tgz /path/to/target" in s + + +def test_certbot(): + with pytest.raises(ValueError): + c = certbot("example.com") + + c = certbot("example.com", email="webmaster@example.com") + s = c.get_statement() + assert "certbot certonly --agree-tos --email webmaster@example.com -n" in s + assert "--webroot -w /var/www/domains/example_com/www -d example.com" in s + + +def test_extract(): + c = extract( + "/path/to/archive.tgz", + absolute=True, + exclude="*.log", + strip=1, + view=True + ) + s = c.get_statement() + assert "tar -xzPv --exclude *.log --strip-components 1" in s + assert "-f /path/to/archive.tgz ./" in s + + +def test_file_append(): + c = file_append("/path/to/file.txt", content="testing = yes") + assert 'echo "testing = yes" >> /path/to/file.txt' in c.get_statement() + + +def test_file_copy(): + c = file_copy("/path/to/file.txt", "/path/to/new-file.txt") + s = c.get_statement() + assert "cp" in s + assert "-n" in s + assert "/path/to/file.txt /path/to/new-file.txt" in s + + c = file_copy("/path/to/dir", "/path/to/newdir", recursive=True) + s = c.get_statement() + assert "cp" in s + assert "-R" in s + assert "/path/to/dir /path/to/newdir" in s + + +def test_file_write(): + c = file_write("/path/to/file.txt", content="testing 123") + assert 'echo "testing 123" > /path/to/file.txt' in c.get_statement() + + content = [ + "I am testing", + "I am testing", + "I am testing", + "testing 123", + ] + c = file_write("/path/to/file.txt", content="\n".join(content)) + s = c.get_statement() + assert "cat > /path/to/file.txt << EOF" in s + assert "I am testing" in s + assert "testing 123" in s + + +def test_mkdir(): + c = mkdir("/path/to/dir", mode=755, recursive=True) + s = c.get_statement() + assert "mkdir" in s + assert "-m 755" in s + assert "-p" in s + assert "/path/to/dir" in s + + +def test_move(): + c = move("/path/to/file.txt", "/path/to/file.txt.b") + assert "mv /path/to/file.txt /path/to/file.txt.b" in c.get_statement() + + +def test_perms(): + c = perms("/path/to/dir", group="www-data", mode=755, owner="deploy", recursive=True) + s = c.get_statement() + assert "chgrp -R www-data /path/to/dir" in s + assert "chown -R deploy /path/to/dir" in s + assert "chmod -R 755 /path/to/dir" in s + + +def test_remove(): + c = remove("/path/to/dir", force=True, recursive=True) + s = c.get_statement() + assert "rm" in s + assert "-f" in s + assert "-r" in s + assert "/path/to/dir" in s + + +def test_rsync(): + c = rsync( + "/path/to/local/", + "/path/to/remote", + links=True, + delete=True, + exclude="deploy/exclude.txt", + recursive=True, + host="example.com", + key_file="~/.ssh/deploy", + user="deploy" + ) + s = c.get_statement() + assert "rsync --cvs-exclude --checksum --compress --copy-links --delete" in s + assert "--exclude-from=deploy/exclude.txt" in s + assert "-P" in s + assert "--recursive /path/to/local/" in s + assert '-e "ssh -i ~/.ssh/deploy -p 22"' in s + assert "deploy@example.com:/path/to/remote" in s + + c = rsync( + "/path/to/local/", + "/path/to/remote", + links=True, + delete=True, + exclude="deploy/exclude.txt", + recursive=True, + ) + s = c.get_statement() + assert "rsync --cvs-exclude --checksum --compress --copy-links --delete" in s + assert "--exclude-from=deploy/exclude.txt" in s + assert "-P" in s + assert "--recursive" in s + assert "/path/to/local/" in s + assert "/path/to/remote" in s + + +def test_run(): + c = run("ls -ls") + assert "ls -ls" in c.get_statement() + + +def test_scopy(): + with pytest.raises(ValueError): + c = scopy("/path/to/local/file.txt", "/path/to/remote/file.txt") + + c = scopy( + "/path/to/local/file.txt", + "/path/to/remote/file.txt", + key_file="~/.ssh/deploy", + host="example.com", + user="deploy" + ) + s = c.get_statement() + assert "scp -i ~/.ssh/deploy" in s + assert "-P 22" in s + assert "/path/to/local/file.txt" in s + assert "deploy@example.com:/path/to/remote/file.txt" in s + + c = scopy( + "/path/to/local/file.txt", + "/path/to/remote/file.txt", + host="example.com", + ) + s = c.get_statement() + assert "scp -P 22" in s + assert "/path/to/local/file.txt" in s + assert "example.com:/path/to/remote/file.txt" in s + + +def test_sed(): + c = sed("/path/to/file.txt", find="testing", replace="123") + s = c.get_statement() + assert "sed -i .b" in s + assert "s/testing/123/g" in s + assert "/path/to/file.txt" in s + + +def test_symlink(): + c = symlink("/var/www/domains", force=True) + s = c.get_statement() + assert "ln -s" in s + assert "-f" in s + assert "/var/www/domains" in s + + +def test_touch(): + c = touch("/path/to/file.txt") + assert "touch /path/to/file.txt" in c.get_statement() + + +class TestFunction(object): + + def test_to_string(self): + f = Function("testing", comment="A test function.") + f.commands.append(touch("/path/to/file.txt")) + s = f.to_string() + assert "# A test function." in s + assert "function testing()" in s + assert "touch /path/to/file.txt" in s diff --git a/tests/test_library_overlays_ubuntu.py b/tests/test_library_overlays_ubuntu.py new file mode 100644 index 0000000..486645c --- /dev/null +++ b/tests/test_library_overlays_ubuntu.py @@ -0,0 +1,86 @@ +import pytest +from scripttease.library.overlays.ubuntu import * + + +def test_apache(): + c = apache("reload") + assert "service apache2 reload" in c.get_statement() + + c = apache("restart") + assert "service apache2 restart" in c.get_statement() + + c = apache("start") + assert "service apache2 start" in c.get_statement() + + c = apache("stop") + assert "service apache2 stop" in c.get_statement() + + c = apache("test") + assert "apachectl configtest" in c.get_statement() + + with pytest.raises(NameError): + apache("nonexistent") + + +def test_apache_disable_module(): + c = apache_disable_module("mod_ssl") + assert "a2dismod mod_ssl" in c.get_statement() + + +def test_apache_disable_site(): + c = apache_disable_site("default") + assert "a2dissite default" in c.get_statement() + + +def test_apache_enable_module(): + c = apache_enable_module("mod_wsgi") + assert "a2enmod mod_wsgi" in c.get_statement() + + +def test_apache_enable_site(): + c = apache_enable_site("example.com") + assert "a2ensite example.com" in c.get_statement() + + +def test_service_reload(): + c = service_reload("postfix") + assert "service postfix reload" in c.get_statement() + + +def test_service_restart(): + c = service_restart("postfix") + assert "service postfix restart" in c.get_statement() + + +def test_service_start(): + c = service_start("postfix") + assert "service postfix start" in c.get_statement() + + +def test_service_stop(): + c = service_stop("postfix") + assert "service postfix stop" in c.get_statement() + + +def test_system(): + c = system("reboot") + assert "reboot" in c.get_statement() + + c = system("update") + assert "apt-get update -y" in c.get_statement() + + c = system("upgrade") + assert "apt-get upgrade -y" in c.get_statement() + + with pytest.raises(NameError): + system("nonexistent") + + +def test_system_install(): + c = system_install("vim") + assert "apt-get install -y vim" in c.get_statement() + + +def test_system_uninstall(): + c = system_uninstall("lftp") + assert "apt-get uninstall -y lftp" in c.get_statement() diff --git a/tests/test_library_scripts.py b/tests/test_library_scripts.py new file mode 100644 index 0000000..ef9e318 --- /dev/null +++ b/tests/test_library_scripts.py @@ -0,0 +1,31 @@ +from scripttease.library.commands import Command, ItemizedCommand +from scripttease.library.overlays.posix import Function +from scripttease.library.scripts import Script + + +class TestScript(object): + + def test_append(self): + s = Script("testing") + s.append(Command("ls -ls", comment="list some stuff")) + s.append(Command("touch /path/to/file.txt", comment="touch a file")) + s.append(Command("ln -s /path/to/file.txt", comment="link to a file")) + + assert len(s.commands) == 3 + + def test_to_string(self): + s = Script("testing") + s.append(Command("ls -ls", comment="list some stuff")) + s.append(Command("touch /path/to/file.txt", comment="touch a file")) + s.append(Command("ln -s /path/to/file.txt", comment="link to a file")) + + s.functions = list() + s.functions.append(Function("testing")) + + output = s.to_string() + assert output == str(s) + + assert "ls -ls" in output + assert "touch /path/to/file.txt" in output + assert "ln -s /path/to/file.txt" in output + assert "function testing()" in output diff --git a/tests/test_overlays.py b/tests/test_overlays.py deleted file mode 100644 index b635cb0..0000000 --- a/tests/test_overlays.py +++ /dev/null @@ -1,19 +0,0 @@ -from scripttease.library.overlays import Overlay - - -class TestOverlay(object): - - def test_get(self): - overlay = Overlay("ubuntu") - overlay.load() - assert overlay.get("nonexistent", "nonexistent") is None - - def test_has(self): - overlay = Overlay("ubuntu") - overlay.load() - assert overlay.has("nonexistent", "nonexistent") is False - assert overlay.has("python", "nonexistent") is False - - def test_load(self): - overlay = Overlay("nonexistent") - assert overlay.load() is False diff --git a/tests/test_parsers_base.py b/tests/test_parsers_base.py new file mode 100644 index 0000000..d728ac4 --- /dev/null +++ b/tests/test_parsers_base.py @@ -0,0 +1,22 @@ +import pytest +from scripttease.library.scripts import Script +# from scripttease.parsers import filter_commands, load_commands +from scripttease.parsers.base import Parser + + +class TestParser(object): + + def test_as_script(self): + p = Parser("/path/to/nonexistent.txt") + assert isinstance(p.as_script(), Script) + + # def test_get_commands(self): + # pass + # + # def test_get_functions(self): + # pass + + def test_load(self): + p = Parser("/path/to/nonexistent.txt") + with pytest.raises(NotImplementedError): + p.load() diff --git a/tests/test_parsers_ini.py b/tests/test_parsers_ini.py new file mode 100644 index 0000000..a2c9f37 --- /dev/null +++ b/tests/test_parsers_ini.py @@ -0,0 +1,45 @@ +import pytest +from scripttease.parsers.ini import Config + + +class TestConfig(object): + + def test_get_commands(self): + c = Config("tests/examples/kitchen_sink.ini") + assert c.load() is True + + assert len(c.get_commands()) > 0 + + def test_get_functions(self): + c = Config("tests/examples/kitchen_sink.ini") + assert c.load() is True + + assert len(c.get_functions()) > 0 + + def test_load(self): + c = Config("nonexistent.ini") + assert c.load() is False + + c = Config("tests/examples/python_examples.ini", overlay="nonexistent") + assert c.load() is False + + c = Config("tests/examples/bad_examples.ini") + assert c.load() is False + + c = Config("tests/examples/kitchen_sink.ini") + assert c.load() is True + + c = Config("tests/examples/bad_command.ini") + assert c.load() is False + + context = { + 'domain_tld': "example_com", + } + c = Config("tests/examples/template_example.ini", context=context) + assert c.load() is True + + context = { + 'domain_tld': "example_com", + } + c = Config("tests/examples/bad_template_example.ini", context=context) + assert c.load() is False diff --git a/tests/test_parsers_utils.py b/tests/test_parsers_utils.py new file mode 100644 index 0000000..d099a65 --- /dev/null +++ b/tests/test_parsers_utils.py @@ -0,0 +1,42 @@ +import pytest +from scripttease.library.commands import Command, ItemizedCommand +from scripttease.parsers import filter_commands, load_commands + + +def test_filter_commands(): + commands = [ + Command("apt-get install apache2 -y", environments=["base"], tags=["web"]), + Command("apt-get install apache-top -y", environments=["live"], tags=["web"]), + Command("pip install django-debug-toolbar", environments=["development"], tags=["django"]), + Command("pip install django", environments=["base"], tags=["django"]), + ] + f1 = filter_commands(commands, environments=["base", "live"]) + assert len(f1) == 3 + + f2 = filter_commands(commands, tags=["django"]) + assert len(f2) == 2 + + f3 = filter_commands(commands, environments=["base", "development"]) + assert len(f3) == 3 + + f4 = filter_commands(commands, environments=["base"], tags=["web"]) + assert len(f4) == 1 + + +def test_load_commands(): + commands = load_commands("nonexistent.xml") + assert commands is None + + commands = load_commands("nonexistent.ini") + assert commands is None + + commands = load_commands("tests/examples/bad_examples.ini") + assert commands is None + + commands = load_commands( + "tests/examples/python_examples.ini", + filters={ + 'tags': ["python-support"], + } + ) + assert len(commands) == 2