commit dea0ee119a97dab5446ef9cea208dc3928884ce9 Author: Shawn Davis Date: Fri Jul 17 17:21:17 2020 -0400 Initial Import 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