From dea0ee119a97dab5446ef9cea208dc3928884ce9 Mon Sep 17 00:00:00 2001 From: Shawn Davis Date: Fri, 17 Jul 2020 17:21:17 -0400 Subject: [PATCH] Initial Import --- .coveragerc | 8 + .gitignore | 14 ++ .status | 1 + DESCRIPTION.txt | 1 + LICENSE.txt | 27 +++ Makefile | 44 +++++ README.markdown | 7 + VERSION.txt | 1 + meta.ini | 9 + pytest.ini | 3 + requirements.pip | 3 + scripttease/__init__.py | 0 scripttease/cli/__init__.py | 0 scripttease/constants.py | 10 + scripttease/data/overlays/ubuntu.ini | 47 +++++ scripttease/library/__init__.py | 0 scripttease/library/commands/__init__.py | 50 +++++ scripttease/library/commands/apache.py | 9 + scripttease/library/commands/base.py | 185 ++++++++++++++++++ scripttease/library/commands/mappings.py | 4 + scripttease/library/commands/packages.py | 23 +++ scripttease/library/commands/python.py | 43 ++++ scripttease/library/factory.py | 0 scripttease/library/overlays.py | 97 +++++++++ scripttease/library/scripts.py | 69 +++++++ scripttease/parsers/__init__.py | 85 ++++++++ scripttease/parsers/base.py | 76 +++++++ scripttease/parsers/ini.py | 150 ++++++++++++++ scripttease/parsers/yaml.py | 0 scripttease/utils.py | 159 +++++++++++++++ setup.py | 52 +++++ tests/__init__.py | 0 tests/conftest.py | 2 + tests/examples/python_examples.ini | 12 ++ tests/readme.markdown | 32 +++ tests/requirements.pip | 2 + tests/test_library_commands_base.py | 86 ++++++++ .../test_library_commands_command_factory.py | 20 ++ tests/test_library_commands_python.py | 18 ++ tests/test_overlays.py | 19 ++ 40 files changed, 1368 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 .status create mode 100644 DESCRIPTION.txt create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.markdown create mode 100644 VERSION.txt create mode 100644 meta.ini create mode 100644 pytest.ini create mode 100644 requirements.pip create mode 100644 scripttease/__init__.py create mode 100644 scripttease/cli/__init__.py create mode 100644 scripttease/constants.py create mode 100644 scripttease/data/overlays/ubuntu.ini create mode 100644 scripttease/library/__init__.py create mode 100644 scripttease/library/commands/__init__.py create mode 100644 scripttease/library/commands/apache.py create mode 100644 scripttease/library/commands/base.py create mode 100644 scripttease/library/commands/mappings.py create mode 100644 scripttease/library/commands/packages.py create mode 100644 scripttease/library/commands/python.py create mode 100644 scripttease/library/factory.py create mode 100644 scripttease/library/overlays.py create mode 100644 scripttease/library/scripts.py create mode 100644 scripttease/parsers/__init__.py create mode 100644 scripttease/parsers/base.py create mode 100644 scripttease/parsers/ini.py create mode 100644 scripttease/parsers/yaml.py create mode 100644 scripttease/utils.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/examples/python_examples.ini create mode 100644 tests/readme.markdown create mode 100644 tests/requirements.pip create mode 100644 tests/test_library_commands_base.py create mode 100644 tests/test_library_commands_command_factory.py create mode 100644 tests/test_library_commands_python.py create mode 100644 tests/test_overlays.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4bb679a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +omit = + docs/* + scripttease/cli/__init__.py + sandbox + setup.py + tmp/* + tmp.* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..819ac89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.b +*.bak +*.pyc +*.swp +.coverage +.DS_Store +.idea +.pytest_cache +__pycache__ +_scraps +docs/build +htmlcov +tmp.* +tmp diff --git a/.status b/.status new file mode 100644 index 0000000..584c847 --- /dev/null +++ b/.status @@ -0,0 +1 @@ +active diff --git a/DESCRIPTION.txt b/DESCRIPTION.txt new file mode 100644 index 0000000..4cf7cd5 --- /dev/null +++ b/DESCRIPTION.txt @@ -0,0 +1 @@ +A collection of classes and commands for automated command line scripting using Python. \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2047a54 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (c) Pleasant Tents, LLC + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of Pleasant Tents, LLC nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..62225aa --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.PHONY: docs help tests + +# The path to source code to be counted with cloc. +CLOC_PATH := scripttease + +# The directory where test coverage is generated. +COVERAGE_PATH := docs/build/html/coverage + +# Attempt to load a local makefile which may override any of the values above. +-include local.makefile + +#> help - Show help. +help: + @echo "" + @echo "Management Commands" + @echo "------------------------------------------------------------------------------" + @cat Makefile | grep "^#>" | sed 's/\#\> //g'; + @echo "" + +#> docs - Generate documentation. +docs: lines + cd docs && make html; + cd docs && make coverage; + open docs/build/coverage/python.txt; + open docs/build/html/index.html; + +#> clean - Remove pyc files. +clean: + find . -name '*.pyc' -delete; + +# lines - Generate lines of code report. +lines: + rm -f docs/source/_data/cloc.csv; + echo "files,language,blank,comment,code" > docs/source/_data/cloc.csv; + cloc $(CLOC_PATH) --csv --quiet --unix --report-file=tmp.csv + tail -n +2 tmp.csv >> docs/source/_data/cloc.csv; + rm tmp.csv; + +#> tests - Run unit tests and generate coverage report. +tests: + coverage run --source=. -m pytest; + coverage html --directory=$(COVERAGE_PATH); + open $(COVERAGE_PATH)/index.html; + diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..74a067e --- /dev/null +++ b/README.markdown @@ -0,0 +1,7 @@ +# Python Script Tease + +![](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) + +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 new file mode 100644 index 0000000..a8b6892 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +5.8.18-d \ No newline at end of file diff --git a/meta.ini b/meta.ini new file mode 100644 index 0000000..1770ea9 --- /dev/null +++ b/meta.ini @@ -0,0 +1,9 @@ +[project] +category = developer +description = A collection of classes and commands for automated command line scripting using Pythonn. +title = Python Script Tease +type = cli + +[business] +code = PTL +name = Pleasant Tents, LLC diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d6a4824 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +norecursedirs = .git _scraps docs sandbox tmp +testpaths = tests diff --git a/requirements.pip b/requirements.pip new file mode 100644 index 0000000..1f4857b --- /dev/null +++ b/requirements.pip @@ -0,0 +1,3 @@ +coverage +django +sphinx \ No newline at end of file diff --git a/scripttease/__init__.py b/scripttease/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripttease/cli/__init__.py b/scripttease/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripttease/constants.py b/scripttease/constants.py new file mode 100644 index 0000000..77670a7 --- /dev/null +++ b/scripttease/constants.py @@ -0,0 +1,10 @@ +import os + +__all__ = ( + "LOGGER_NAME", + "PATH_TO_SCRIPT_TEASE", +) + +LOGGER_NAME = "script-tease" + +PATH_TO_SCRIPT_TEASE = os.path.abspath(os.path.dirname(__file__)) diff --git a/scripttease/data/overlays/ubuntu.ini b/scripttease/data/overlays/ubuntu.ini new file mode 100644 index 0000000..fb6a79a --- /dev/null +++ b/scripttease/data/overlays/ubuntu.ini @@ -0,0 +1,47 @@ +[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/__init__.py b/scripttease/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripttease/library/commands/__init__.py b/scripttease/library/commands/__init__.py new file mode 100644 index 0000000..f35c2f2 --- /dev/null +++ b/scripttease/library/commands/__init__.py @@ -0,0 +1,50 @@ +# Imports + +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_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 diff --git a/scripttease/library/commands/apache.py b/scripttease/library/commands/apache.py new file mode 100644 index 0000000..4b4bc10 --- /dev/null +++ b/scripttease/library/commands/apache.py @@ -0,0 +1,9 @@ +# Classes + + +class Apache(object): + + def __init__(self, subcommand, *args, **kwargs): + if subcommand == "disable": + pass + diff --git a/scripttease/library/commands/base.py b/scripttease/library/commands/base.py new file mode 100644 index 0000000..d4b5fba --- /dev/null +++ b/scripttease/library/commands/base.py @@ -0,0 +1,185 @@ +# Classes + + +class Command(object): + + 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): + self.comment = comment + self.condition = condition + self.cd = cd + self.environments = environments or list() + self.function = function + self.prefix = prefix + self.register = register + self.shell = shell + self.statement = statement + self.stop = stop + self.tags = tags or list() + + if isinstance(sudo, Sudo): + self.sudo = sudo + elif type(sudo) is str: + self.sudo = Sudo(enabled=True, user=sudo) + elif sudo is True: + self.sudo = Sudo(enabled=True) + else: + self.sudo = Sudo() + + self._attributes = kwargs + + def __getattr__(self, item): + return self._attributes.get(item) + + def __repr__(self): + if self.comment is not None: + return "<%s %s>" % (self.__class__.__name__, self.comment) + + return "<%s>" % self.__class__.__name__ + + def get_statement(self, cd=False): + """Get the full statement. + + :param cd: Include the directory change, if given. + :type cd: bool + + :rtype: str + + """ + a = list() + + if cd and self.cd is not None: + a.append("( cd %s &&" % self.cd) + + if self.prefix is not None: + a.append("%s &&" % self.prefix) + + if self.sudo: + statement = "sudo -u %s %s" % (self.sudo.user, self._get_statement()) + else: + statement = self._get_statement() + + a.append("%s" % statement) + + if cd and self.cd is not None: + a.append(")") + + b = list() + if self.comment is not None: + b.append("# %s" % self.comment) + + if self.condition is not None: + b.append("if [[ %s ]]; then %s; fi;" % (self.condition, " ".join(a))) + else: + b.append(" ".join(a)) + + if self.register is not None: + b.append("%s=$?;" % self.register) + + if self.stop: + b.append("if [[ $%s -gt 0 ]]; exit 1; fi;" % self.register) + elif self.stop: + b.append("if [[ $? -gt 0 ]]; exit 1; fi;") + else: + pass + + return "\n".join(b) + + def _get_statement(self): + """By default, get the statement passed upon command initialization. + + :rtype: str + + """ + return self.statement + + +class ItemizedCommand(object): + + def __init__(self, command_class, items, *args, **kwargs): + """Initialize the command. + + :param command_class: The command class to be used. + :type command_class: class + + :param items: The command arguments. + :type items: list[str] + + :param args: The itemized arguments. ``$item`` should be included. + + :param kwargs: Keyword arguments are passed to the command class upon instantiation. + + """ + self.args = args + self.command_class = command_class + self.items = items + self.kwargs = kwargs + + def __getattr__(self, item): + return self.kwargs.get(item) + + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.command_class.__name__) + + def get_commands(self): + """Get the commands to be executed. + + :rtype: list[BaseType(Command)] + + """ + kwargs = self.kwargs.copy() + + a = list() + for item in self.items: + args = list() + for arg in self.args: + args.append(arg.replace("$item", item)) + + command = self.command_class(*args, **kwargs) + a.append(command) + + return a + + def get_statement(self, cd=False): + """Override to get multiple commands.""" + kwargs = self.kwargs.copy() + comment = kwargs.pop("comment", "execute multiple commands") + + a = list() + # a.append("# %s" % comment) + + commands = self.get_commands() + for c in commands: + a.append(c.get_statement(cd=cd)) + a.append("") + + # for item in self.items: + # args = list() + # for arg in self.args: + # args.append(arg.replace("$item", item)) + # + # command = self.command_class(*args, **kwargs) + # a.append(command.preview(cwd=cwd)) + # a.append("") + + return "\n".join(a) + + +class Sudo(object): + """Helper class for defining sudo options.""" + + def __init__(self, enabled=False, user="root"): + """Initialize the helper. + + :param enabled: Indicates sudo is enabled. + :type enabled: bool + + :param user: The user to be invoked. + :type user: str + + """ + self.enabled = enabled + self.user = user + + def __bool__(self): + return self.enabled diff --git a/scripttease/library/commands/mappings.py b/scripttease/library/commands/mappings.py new file mode 100644 index 0000000..6cfc978 --- /dev/null +++ b/scripttease/library/commands/mappings.py @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..a23d255 --- /dev/null +++ b/scripttease/library/commands/packages.py @@ -0,0 +1,23 @@ +# 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 new file mode 100644 index 0000000..3c9fc6d --- /dev/null +++ b/scripttease/library/commands/python.py @@ -0,0 +1,43 @@ +# 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/factory.py b/scripttease/library/factory.py new file mode 100644 index 0000000..e69de29 diff --git a/scripttease/library/overlays.py b/scripttease/library/overlays.py new file mode 100644 index 0000000..8a9ffe9 --- /dev/null +++ b/scripttease/library/overlays.py @@ -0,0 +1,97 @@ +# 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 diff --git a/scripttease/library/scripts.py b/scripttease/library/scripts.py new file mode 100644 index 0000000..8849105 --- /dev/null +++ b/scripttease/library/scripts.py @@ -0,0 +1,69 @@ +# Classes + + +class Script(object): + """A script is a collection of commands.""" + + def __init__(self, name, commands=None, functions=None, shell="bash"): + """Initialize a script. + + :param name: The name of the script. Note: This becomes the file name. + :type name: str + + :param commands: The commands to be included. + :type commands: list[BaseType[Command]] + + :param functions: The functions to be included. + :type functions: list[Function] + + :param shell: The shell to use for the script. + :type shell: str + + """ + self.commands = commands or list() + self.functions = functions + self.name = name + self.shell = shell + + def __str__(self): + return self.to_string() + + def append(self, command): + """Append a command instance to the script's commands. + + :param command: The command instance to be included. + :type command: BaseType[Command] | ItemizedCommand + + """ + self.commands.append(command) + + def to_string(self, shebang="#! /usr/bin/env %(shell)s"): + """Export the script as a string. + + :param shebang: The shebang to be included. Set to ``None`` to omit the shebang. + :type shebang: str + + :rtype: str + + """ + a = list() + + if shebang is not None: + a.append("%s" % {'shell': self.shell}) + a.append("") + + if self.functions is not None: + for function in self.functions: + a.append(function.preview()) + a.append("") + + 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("") + + return "\n".join(a) diff --git a/scripttease/parsers/__init__.py b/scripttease/parsers/__init__.py new file mode 100644 index 0000000..1976ae9 --- /dev/null +++ b/scripttease/parsers/__init__.py @@ -0,0 +1,85 @@ +# 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 diff --git a/scripttease/parsers/base.py b/scripttease/parsers/base.py new file mode 100644 index 0000000..ca3c2d9 --- /dev/null +++ b/scripttease/parsers/base.py @@ -0,0 +1,76 @@ +# Imports + +from superpython.utils import File +from ..library.overlays import Overlay +from ..library.scripts import Script + +# Exports + +__all__ = ( + "Parser" +) + +# Classes + + +class Parser(File): + """Base class for implementing a command parser.""" + + def __init__(self, path, context=None, locations=None, options=None, overlay=None): + super().__init__(path) + + self.context = context + self.is_loaded = False + self.locations = locations or list() + self.options = options or dict() + self.overlay = overlay or Overlay("ubuntu") + self._commands = list() + self._functions = list() + + self.overlay.load() + + def as_script(self): + """Convert loaded commands to a script. + + :rtype: Script + + """ + return Script( + "%s.sh" % self.name, + commands=self.get_commands(), + functions=self.get_functions() + ) + + def get_commands(self): + """Get the commands that have been loaded from the file. + + :rtype: list[BaseType[scripttease.library.commands.base.Command]] + + """ + a = list() + for c in self._commands: + if c.function is not None: + continue + + a.append(c) + + return a + + def get_functions(self): + """Get the functions that have been loaded from the file. + + :rtype: list[scripttease.library.scripts.Function] + + """ + a = list() + for f in self._functions: + for c in self._commands: + if c.function is not None and f.name == c.function: + f.commands.append(c) + + a.append(f) + + return a + + def load(self): + raise NotImplementedError() diff --git a/scripttease/parsers/ini.py b/scripttease/parsers/ini.py new file mode 100644 index 0000000..690762c --- /dev/null +++ b/scripttease/parsers/ini.py @@ -0,0 +1,150 @@ +# Imports + +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 + +log = logging.getLogger(LOGGER_NAME) + +# Exports + +__all__ = ( + "Config", +) + +# Classes + + +class Config(Parser): + """An INI configuration for loading commands.""" + + def load(self): + if not self.exists: + return False + + ini = self._load_ini() + if ini is None: + return False + + success = True + for comment in ini.sections(): + args = list() + command_name = None + count = 0 + kwargs = self.options.copy() + + for key, value in ini.items(comment): + # The first key/value pair is the command name and arguments. + if count == 0: + command_name = key + + if value[0] == '"': + args.append(value.replace('"', "")) + else: + args = value.split(" ") + else: + _key, _value = self._get_key_value(key, value) + + kwargs[_key] = _value + + count += 1 + + command = command_factory(command_name, comment, self.overlay, *args, **kwargs) + if command is not None: + # if isinstance(command, Function): + # self._functions.append(command) + # elif isinstance(command, Include): + # subcommands = self._load_include(command) + # if subcommands is not None: + # self._commands += subcommands + # elif isinstance(command, Template): + # self._load_template(command) + # self._commands.append(command) + # elif isinstance(command, ItemizedCommand) and issubclass(command.command_class, Template): + # for c in command.get_commands(): + # self._load_template(c) + # self._commands.append(c) + # else: + # self._commands.append(command) + self._commands.append(command) + else: + success = False + + self.is_loaded = success + return self.is_loaded + + # noinspection PyMethodMayBeStatic + def _get_key_value(self, key, value): + """Process a key/value pair from an INI section. + + :param key: The key to be processed. + :type key: str + + :param value: The value to be processed. + + :rtype: tuple + :returns: The key and value, both of which may be modified from the originals. + + """ + if key in ("environments", "environs", "envs", "env"): + _key = "environments" + _value = split_csv(value) + elif key in ("func", "function"): + _key = "function" + _value = value + elif key == "items": + _key = "items" + _value = split_csv(value) + elif key == "tags": + _key = "tags" + _value = split_csv(value) + else: + _key = key + _value = smart_cast(value) + + return _key, _value + + def _load_ini(self): + """Load the configuration file. + + :rtype: ConfigParser | None + + """ + ini = ConfigParser() + if self.context is not None: + try: + content = parse_jinja_template(self.path, self.context) + except Exception as e: + log.error("Failed to parse %s as template: %s" % (self.path, e)) + return None + else: + content = read_file(self.path) + + try: + ini.read_string(content) + return ini + except ParsingError as e: + 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) diff --git a/scripttease/parsers/yaml.py b/scripttease/parsers/yaml.py new file mode 100644 index 0000000..e69de29 diff --git a/scripttease/utils.py b/scripttease/utils.py new file mode 100644 index 0000000..23068f7 --- /dev/null +++ b/scripttease/utils.py @@ -0,0 +1,159 @@ +# 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 new file mode 100644 index 0000000..9701f3b --- /dev/null +++ b/setup.py @@ -0,0 +1,52 @@ +# See https://packaging.python.org/en/latest/distributing.html +# and https://docs.python.org/2/distutils/setupscript.html +# and https://pypi.python.org/pypi?%3Aaction=list_classifiers +from setuptools import setup, find_packages + + +def read_file(path): + with open(path, "r") as f: + contents = f.read() + f.close() + return contents + + +setup( + name='python-script-tease', + 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', + packages=find_packages(), + include_package_data=True, + install_requires=[ + "jinja2", + "pygments", + "python-myninjas", + ], + dependency_links=[ + "https://bitbucket.com/myninjas/python-myninjas/master.tar.gz#python-myninjas", + ], + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + zip_safe=False, + tests_require=[ + "coverage", + ], + test_suite='runtests.runtests', + entry_points={ + 'console_scripts': [ + 'tease = script_tease.cli:main_command', + ], + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6abfe65 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,2 @@ +import os +import pytest diff --git a/tests/examples/python_examples.ini b/tests/examples/python_examples.ini new file mode 100644 index 0000000..314e693 --- /dev/null +++ b/tests/examples/python_examples.ini @@ -0,0 +1,12 @@ +[install the virtualenv package] +pip = virtualenv + +[create a virtual environment] +virtualenv = python +cd = /path/to/project + +[install pillow] +pip = Pillow +cd = /path/to/project +upgrade = yes +venv = python diff --git a/tests/readme.markdown b/tests/readme.markdown new file mode 100644 index 0000000..e823e1f --- /dev/null +++ b/tests/readme.markdown @@ -0,0 +1,32 @@ +# Testing + +## Set Up for Testing + +Install requirements: + +``pip install tests/requirements.pip`` + +## Running Tests + +Run all tests with coverage: + +``make tests`` + +Run a specific test: + +``python -m pytest tests/units/path/to/test.py`` + +Example: + +``python -m pytest tests/units/shortcuts/test_shortcuts.py`` + +To allow output from print statements within a test method, add the ``-s`` switch: + +``python -m pytest -s tests/units/path/to/test.py`` + +> Tip: Add ``-v`` to list the tests with PASS/FAIL. + +## Reference + +- [coverage](https://coverage.readthedocs.io/en/v4.5.x/) +- [pytest](https://pytest.org) diff --git a/tests/requirements.pip b/tests/requirements.pip new file mode 100644 index 0000000..e36ca7a --- /dev/null +++ b/tests/requirements.pip @@ -0,0 +1,2 @@ +coverage +pytest \ No newline at end of file diff --git a/tests/test_library_commands_base.py b/tests/test_library_commands_base.py new file mode 100644 index 0000000..4271f0d --- /dev/null +++ b/tests/test_library_commands_base.py @@ -0,0 +1,86 @@ +from scripttease.library.commands.base import Command, ItemizedCommand, Sudo +from scripttease.library.commands.python import Pip +from scripttease.library.overlays import Overlay + + +class TestCommand(object): + + def test_getattr(self): + c = Command("ls -ls", extra=True) + assert c.extra is True + + def test_get_statement(self): + c = Command( + "ls -ls", + comment="kitchen sink", + condition="$last_command -eq 0", + cd="/path/to/project", + prefix="source python/bin/active", + register="list_success", + stop=True, + sudo="deploy" + ) + statement = c.get_statement(cd=True) + assert "( cd" in statement + assert "sudo" in statement + assert ")" in statement + assert "# kitchen sink" in statement + assert "if [[ $last_command" in statement + assert "list_success=$?" in statement + assert "if [[ $list_success" in statement + + c = Command( + "ls -ls", + stop=True + ) + statement = c.get_statement() + assert "if [[ $?" in statement + + def test_init(self): + c = Command("ls -ls", sudo=Sudo(user="deploy")) + assert isinstance(c.sudo, Sudo) + assert c.sudo.user == "deploy" + + c = Command("ls -ls", sudo="deploy") + assert isinstance(c.sudo, Sudo) + assert c.sudo.user == "deploy" + + c = Command("ls -ls", sudo=True) + assert isinstance(c.sudo, Sudo) + assert c.sudo.user == "root" + + c = Command("ls -ls") + assert isinstance(c.sudo, Sudo) + assert c.sudo.user == "root" + assert c.sudo.enabled is False + + def test_repr(self): + c = Command("ls -ls", comment="listing") + assert repr(c) == "" + + c = Command("ls -ls") + assert repr(c) == "" + + +class TestItemizedCommand(object): + + def test_getattr(self): + c = ItemizedCommand(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") + commands = c.get_commands() + for i in commands: + assert isinstance(i, Pip) + + def test_get_statement(self): + c = ItemizedCommand(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) == "" diff --git a/tests/test_library_commands_command_factory.py b/tests/test_library_commands_command_factory.py new file mode 100644 index 0000000..4919f2d --- /dev/null +++ b/tests/test_library_commands_command_factory.py @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..18b89d2 --- /dev/null +++ b/tests/test_library_commands_python.py @@ -0,0 +1,18 @@ +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_overlays.py b/tests/test_overlays.py new file mode 100644 index 0000000..b635cb0 --- /dev/null +++ b/tests/test_overlays.py @@ -0,0 +1,19 @@ +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