diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..287f16a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +/.coverage +/dist/ +/htmlcov/ +/*.egg-info/ +ionit.1 diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..f92acda --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ionit is licensed under ISC: + +Copyright (C) 2018, Benjamin Drung + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cdf4e2d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +recursive-include tests *.jinja *.json *.py *.yaml echo pylint.conf +include ionit.1.md +include ionit.py +include LICENSE diff --git a/README.md b/README.md new file mode 100644 index 0000000..0af9629 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +ionit +===== + +ionit is a simple and small configuration templating tool. It collects a context +and renders Jinja templates in a given directory. The context can be either +static JSON or YAML files or dynamic Python files. Python files can also define +functions passed through to the rendering. + +The context filenames needs to end with `.json` for JSON, `.py` for Python, +and `.yaml` for YAML. The context files are read in alphabetical order. If the +same key is defined by multiple context files, the file that is read later takes +precedence. It is recommended to prefix the files with a number in case the +order is relevant. + +ionit comes with an early boot one shot service that is executed before the +networking service which allows one to generate configurations files for the +networking and other services before they are started. In this regard, ionit can +act as tiny stepbrother of cloud-init. + +Python modules +============== + +Python modules can define a `collect_context` function. This function is called +by ionit and the current context is passed as parameter. The current context can +be used to derive more context information, but this variable should not be +modified. `collect_context` must return a dictionary (can be empty) or raise an +exception, which will be caught by ionit. + +Python modules can also define functions which can be called from the Jinja +template on rendering. Use the `ionit_plugin.function` decorator to mark the +functions to export. + +Note that the functions names should not collide with other keys from the +context. If one Python module defines a function and a value in the context +with the same name, the value in the context will take precedence. + +An example Python module might look like: + +```python +import ionit_plugin + + +@ionit_plugin.function +def double(value): + return 2 * value + + +@ionit_plugin.function +def example_function(): + return "Lorem ipsum" + + +def collect_context(current_context): + return {"key": "value"} +``` + +Prerequisites +============= + +* Python >= 3.4 +* Python modules: + * jinja2 + * yaml or ruamel.yaml +* pandoc (to generate `ionit.1` man page from `ionit.1.md`) + +The test cases have additional requirements: + +* flake8 +* pylint + +Examples +======== + +Static context +-------------- + +This example is taken from one test case and demonstrates how ionit will collect +the context from one JSON and one YAML file and renders one template: + +``` +user@host:~/ionit$ cat tests/config/static/first.json +{"first": 1} +user@host:~/ionit$ cat tests/config/static/second.yaml +second: 2 +user@host:~/ionit$ cat tests/template/static/counting.jinja +Counting: +* {{ first }} +* {{ second }} +* 3 +user@host:~/ionit$ ./ionit -c tests/config/static -t tests/template/static +2018-08-08 17:39:06,956 ionit INFO: Reading configuration file 'tests/config/static/first.json'... +2018-08-08 17:39:06,956 ionit INFO: Reading configuration file 'tests/config/static/second.yaml'... +2018-08-08 17:39:06,960 ionit INFO: Rendered 'tests/template/static/counting.jinja' to 'tests/template/static/counting'. +user@host:~/ionit$ cat tests/template/static/counting +Counting: +* 1 +* 2 +* 3 +``` + +Python functions +---------------- + +This example is taken from one test case and demonstrates how Python functions +can be defined to be used when rendering: + +``` +user@host:~/ionit$ cat tests/config/function/function.py +import ionit_plugin + + +@ionit_plugin.function +def answer_to_all_questions(): + return 42 +user@host:~/ionit$ cat tests/template/function/Document.jinja +The answer to the Ultimate Question of Life, The Universe, and Everything is {{ answer_to_all_questions() }}. +user@host:~/ionit$ ./ionit -c tests/config/function -t tests/template/function +2018-08-13 11:58:16,905 ionit INFO: Loading Python module 'function' from 'tests/config/function/function.py'... +2018-08-13 11:58:16,909 ionit INFO: Rendered 'tests/template/function/Document.jinja' to 'tests/template/function/Document'. +user@host:~/ionit$ cat tests/template/function/Document +The answer to the Ultimate Question of Life, The Universe, and Everything is 42. +``` + +Contributing +============ + +Contributions are welcome. The source code has 100% test coverage, which should +be preserved. So please provide a test case for each bugfix and one or more +test cases for each new feature. Please follow +[How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/) +for writing good commit messages. diff --git a/ionit b/ionit new file mode 100755 index 0000000..79d1f35 --- /dev/null +++ b/ionit @@ -0,0 +1,208 @@ +#!/usr/bin/python3 + +# Copyright (C) 2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Render configuration files from Jinja templates""" + +import argparse +import importlib.util +import json +import logging +import os +import sys + +import jinja2 +import yaml + +import ionit_plugin + +LOG_FORMAT = '%(asctime)s %(name)s %(levelname)s: %(message)s' +SCRIPT_NAME = "ionit" + + +class PythonModuleException(Exception): + """Exception raised when loading a Python context file fails""" + pass + + +def load_python_plugin(file_path, current_context): + """Collect context from given Python module + + The specified Python file needs to be a valid plug-in which provides + a collect_context function that takes the current context as parameter + and returns a dict containing the context. + """ + logger = logging.getLogger(SCRIPT_NAME) + module_name = os.path.splitext(os.path.basename(file_path))[0] + logger.info("Loading Python module '%s' from '%s'...", module_name, file_path) + function_collector = ionit_plugin.FunctionCollector() + function_collector.clear() + dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + if sys.version_info[:2] < (3, 5): + # Backwards compatibility for Debian jessie + import imp # pragma: no cover + module = imp.load_source(module_name, file_path) # pragma: no cover + else: + spec = importlib.util.spec_from_file_location(module_name, file_path) + # module_from_spec not available in Python < 3.5. + module = importlib.util.module_from_spec(spec) # pylint: disable=no-member + spec.loader.exec_module(module) + except Exception: + logger.exception("Importing Python module '%s' failed:", file_path) + raise PythonModuleException() + finally: + sys.dont_write_bytecode = dont_write_bytecode + + context = function_collector.functions.copy() + if hasattr(module, "collect_context"): + try: + new_context = module.collect_context(current_context) + except Exception: + logger.exception("Calling collect_context() from '%s' failed:", file_path) + raise PythonModuleException() + context.update(new_context) + + if not context: + logger.warning("Python module '%s' does neither define a collect_context function, " + "nor export functions (using the ionit_plugin.function decorator).", + file_path) + + return context + + +def collect_context(directory): + """Collect context that will be used when rendering the templates""" + logger = logging.getLogger(SCRIPT_NAME) + logger.debug("Collecting context from '%s'...", directory) + try: + files = sorted(os.listdir(directory)) + except OSError as error: + logger.warning("Failed to read configuration directory: %s", error) + files = [] + + failures = 0 + context = {} + + for filename in files: + file_context = None + file = os.path.join(directory, filename) + extension = os.path.splitext(filename)[1] + try: + if extension == ".json": + logger.info("Reading configuration file '%s'...", file) + with open(file) as config_file: + file_context = json.load(config_file) + elif extension == ".py": + file_context = load_python_plugin(file, context) + elif extension == ".yaml": + logger.info("Reading configuration file '%s'...", file) + with open(file) as config_file: + file_context = yaml.load(config_file) + else: + logger.info("Skipping configuration file '%s', because it does not end with " + "'.json', '.py', or '.yaml'.", file) + continue + except PythonModuleException: + failures += 1 + continue + except (OSError, ValueError, yaml.error.YAMLError) as error: + types = {".json": "JSON", ".py": "Python code", ".yaml": "YAML"} + logger.error("Failed to read %s from '%s': %s", types[extension], file, error) + failures += 1 + continue + + logger.debug("Parsed context from '%s': %s", file, file_context) + if file_context: + try: + context.update(file_context) + except (TypeError, ValueError) as error: + logger.debug("Current context: %s", context) + logger.error("Failed to update context with content from '%s': %s", + file, error) + failures += 1 + continue + + return failures, context + + +def render_templates(template_dir, context, template_extension): + """ + Search in the template directory for template files and render them with the context + """ + logger = logging.getLogger(SCRIPT_NAME) + logger.debug("Searching in directory '%s' for Jinja templates...", template_dir) + failures = 0 + env = jinja2.Environment(keep_trailing_newline=True, + loader=jinja2.FileSystemLoader(template_dir), + undefined=jinja2.StrictUndefined) + for name in env.list_templates(extensions=[template_extension]): + try: + template = env.get_template(name) + except jinja2.TemplateError: + logger.exception("Failed to load template '%s':", os.path.join(template_dir, name)) + failures += 1 + continue + + rendered_filename = os.path.splitext(template.filename)[0] + logger.debug("Rendering template '%s' to '%s'...", template.filename, rendered_filename) + try: + rendered = template.render(context) + except Exception: # pylint: disable=broad-except + logger.exception("Failed to render '%s':", template.filename) + failures += 1 + continue + + try: + with open(rendered_filename, "w") as output_file: + output_file.write(rendered) + except OSError as error: + logger.error("Failed to write rendered template to '%s': %s", rendered_filename, error) + failures += 1 + continue + + logger.info("Rendered '%s' to '%s'.", template.filename, rendered_filename) + + return failures + + +def main(argv): + """Main function with argument parsing""" + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--config", default="/etc/ionit", + help="Configuration directory containing context for rendering (default: " + "%(default)s)") + parser.add_argument("-t", "--templates", default="/etc", + help="Directory to search for Jinja templates (default: %(default)s)") + parser.add_argument("-e", "--template-extension", default="jinja", + help="Extension to look for in template directory (default: %(default)s)") + parser.add_argument("--debug", dest="log_level", help="Print debug output", + action="store_const", const=logging.DEBUG, default=logging.INFO) + parser.add_argument("-q", "--quiet", dest="log_level", + help="Decrease output verbosity to warnings and errors", + action="store_const", const=logging.WARNING) + args = parser.parse_args(argv) + logging.basicConfig(level=args.log_level, format=LOG_FORMAT) + logger = logging.getLogger(SCRIPT_NAME) + + failures, context = collect_context(args.config) + logger.debug("Context: %s", context) + failures += render_templates(args.templates, context, args.template_extension) + return failures + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) # pragma: no cover diff --git a/ionit.1.md b/ionit.1.md new file mode 100644 index 0000000..533ba7c --- /dev/null +++ b/ionit.1.md @@ -0,0 +1,93 @@ +--- +date: 2018-08-13 +footer: ionit +header: "ionit's Manual" +layout: page +license: 'Licensed under the ISC license' +section: 1 +title: IONIT +--- + +# NAME + +ionit - Render configuration files from Jinja templates + +# SYNOPSIS + +**ionit** [**OPTIONS**] + +# DESCRIPTION + +**ionit** is a simple and small configuration templating tool. It collects a +context and renders Jinja templates in a given directory. The context can be +either static JSON or YAML files or dynamic Python files. Python files can also +define functions passed through to the rendering. + +The context filenames needs to end with *.json* for JSON, *.py* for Python, +and *.yaml* for YAML. The context files are read in alphabetical order. If the +same key is defined by multiple context files, the file that is read later takes +precedence. It is recommended to prefix the files with a number in case the +order is relevant. + +**ionit** comes with an early boot one shot service that is executed before the +networking service which allows one to generate configurations files for the +networking and other services before they are started. In this regard, ionit can +act as tiny stepbrother of cloud-init. + +# OPTIONS + +**-c** */path/to/config*, **--config** */path/to/config* +: Configuration directory containing context for rendering (default: +*/etc/ionit*) + +**-t** */path/to/templates*, **--templates** */path/to/templates* +: Directory to search for Jinja templates (default: */etc*) + +**-e** *TEMPLATE_EXTENSION*, **--template-extension** *TEMPLATE_EXTENSION* +: Extension to look for in template directory (default: *jinja*) + +**--debug** +: Print debug output + +**-q**, **--quiet** +: Decrease output verbosity to warnings and errors. + +# PYTHON MODULES + +Python modules can define a *collect_context* function. This function is called +by ionit and the current context is passed as parameter. The current context can +be used to derive more context information, but this variable should not be +modified. *collect_context* must return a dictionary (can be empty) or raise an +exception, which will be caught by ionit. + +Python modules can also define functions which can be called from the Jinja +template on rendering. Use the *ionit_plugin.function* decorator to mark the +functions to export. + +Note that the functions names should not collide with other keys from the +context. If one Python module defines a function and a value in the context +with the same name, the value in the context will take precedence. + +An example Python module might look like: + +```python +import ionit_plugin + + +@ionit_plugin.function +def double(value): + return 2 * value + + +@ionit_plugin.function +def example_function(): + return "Lorem ipsum" + + +def collect_context(current_context): + return {"key": "value"} +``` + +# AUTHOR + +Benjamin Drung diff --git a/ionit.py b/ionit.py new file mode 120000 index 0000000..8ce1e53 --- /dev/null +++ b/ionit.py @@ -0,0 +1 @@ +ionit \ No newline at end of file diff --git a/ionit.service b/ionit.service new file mode 100644 index 0000000..3da5010 --- /dev/null +++ b/ionit.service @@ -0,0 +1,17 @@ +[Unit] +Description=Render configuration files from Jinja templates +Documentation=man:ionit(1) +DefaultDependencies=no +After=local-fs.target +Before=network-pre.target openibd.service shutdown.target sysinit.target +Wants=network-pre.target +RequiresMountsFor=/usr + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/ionit +ExecReload=/usr/bin/ionit + +[Install] +WantedBy=multi-user.target diff --git a/ionit_plugin.py b/ionit_plugin.py new file mode 100644 index 0000000..84a9431 --- /dev/null +++ b/ionit_plugin.py @@ -0,0 +1,51 @@ +# Copyright (C) 2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Helper function for writing ionit plugins""" + +import logging + + +class FunctionCollector(): + """Collect functions for the Jinja renderer""" + functions = {} + + def __new__(cls): + # Singleton + if not hasattr(cls, 'instance') or not cls.instance: + cls.instance = super().__new__(cls) + return cls.instance + + def clear(self): + """Reset the list of functions + + Call this method between importing different modules. + """ + self.functions = {} + + def function(self, func): + """Function decorator to collect functions for Jinja rendering + + Functions using this decorator will be collected and ionit will + use them in the context to make these functions available for + the Jinja template rendering. + + """ + logger = logging.getLogger(__name__) + logger.debug("Collecting function '%s'.", func.__name__) + self.functions[func.__name__] = func + return func + + +function = FunctionCollector().function # false positive, pylint: disable=invalid-name diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..f08401a --- /dev/null +++ b/setup.py @@ -0,0 +1,52 @@ +#!/usr/bin/python3 + +# Copyright (C) 2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Setup for ionit""" + +import subprocess + +from setuptools import setup + + +def systemd_unit_path(): + """Determine path for systemd units""" + try: + command = ["pkg-config", "--variable=systemdsystemunitdir", "systemd"] + path = subprocess.check_output(command, stderr=subprocess.STDOUT) + return path.decode().replace("\n", "") + except (subprocess.CalledProcessError, OSError): + return "/lib/systemd/system" + + +if __name__ == "__main__": + setup( + name="ionit", + version="0.1", + description="Render configuration files from Jinja templates", + long_description=( + "ionit is a simple and small configuration templating tool. It collects a context and " + "renders Jinja templates in a given directory. The context can be either static JSON " + "or YAML files or dynamic Python files. Python files can also define functions passed " + "through to the rendering."), + author="Benjamin Drung", + author_email="benjamin.drung@profitbricks.com", + url="https://github.com/bdrung/ionit", + license="ISC", + install_requires=["jinja2"], + scripts=["ionit"], + py_modules=["ionit_plugin"], + data_files=[(systemd_unit_path(), ['ionit.service'])] + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..74c713f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,57 @@ +# Copyright (C) 2017, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""Helper functions for testing ionit""" + +import inspect +import os +import sys +import unittest + + +def get_source_files(): + """Return a list of sources files/directories (to check with flake8/pylint)""" + scripts = ["ionit"] + modules = ["tests"] + py_files = ["ionit_plugin.py", "setup.py"] + + files = [] + for code_file in scripts + modules + py_files: + is_script = code_file in scripts + if not os.path.exists(code_file): # pragma: no cover + # The alternative path is needed for Debian's pybuild + alternative = os.path.join(os.environ.get("OLDPWD", ""), code_file) + code_file = alternative if os.path.exists(alternative) else code_file + if is_script: + with open(code_file, "rb") as script_file: + shebang = script_file.readline().decode("utf-8") + if ((sys.version_info[0] == 3 and "python3" in shebang) or + ("python" in shebang and "python3" not in shebang)): + files.append(code_file) + else: + files.append(code_file) + return files + + +def unittest_verbosity(): + """Return the verbosity setting of the currently running unittest + program, or None if none is running. + """ + frame = inspect.currentframe() + while frame: + self = frame.f_locals.get("self") + if isinstance(self, unittest.TestProgram): + return self.verbosity + frame = frame.f_back + return None # pragma: no cover diff --git a/tests/config/additional-file/echo b/tests/config/additional-file/echo new file mode 100644 index 0000000..e74b7d4 --- /dev/null +++ b/tests/config/additional-file/echo @@ -0,0 +1,2 @@ +#!/bin/sh +echo "$@" diff --git a/tests/config/additional-file/valid.yaml b/tests/config/additional-file/valid.yaml new file mode 100644 index 0000000..b4f46b7 --- /dev/null +++ b/tests/config/additional-file/valid.yaml @@ -0,0 +1 @@ +key: value diff --git a/tests/config/empty/empty.py b/tests/config/empty/empty.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/exception/exception.py b/tests/config/exception/exception.py new file mode 100644 index 0000000..ceaf30e --- /dev/null +++ b/tests/config/exception/exception.py @@ -0,0 +1,2 @@ +def collect_context(_): + raise Exception("Oops.") diff --git a/tests/config/function/function.py b/tests/config/function/function.py new file mode 100644 index 0000000..b48c162 --- /dev/null +++ b/tests/config/function/function.py @@ -0,0 +1,6 @@ +import ionit_plugin + + +@ionit_plugin.function +def answer_to_all_questions(): + return 42 diff --git a/tests/config/invalid-json/invalid.json b/tests/config/invalid-json/invalid.json new file mode 100644 index 0000000..f4db31b --- /dev/null +++ b/tests/config/invalid-json/invalid.json @@ -0,0 +1,3 @@ +{ + "invalid": "json", +} diff --git a/tests/config/invalid-python/invalid.py b/tests/config/invalid-python/invalid.py new file mode 100644 index 0000000..43e16dd --- /dev/null +++ b/tests/config/invalid-python/invalid.py @@ -0,0 +1 @@ +int("invalid") diff --git a/tests/config/invalid-yaml/invalid.yaml b/tests/config/invalid-yaml/invalid.yaml new file mode 100644 index 0000000..63fa846 --- /dev/null +++ b/tests/config/invalid-yaml/invalid.yaml @@ -0,0 +1 @@ +invalid: yaml: file diff --git a/tests/config/non-dict/invalid.yaml b/tests/config/non-dict/invalid.yaml new file mode 100644 index 0000000..276760c --- /dev/null +++ b/tests/config/non-dict/invalid.yaml @@ -0,0 +1 @@ +this-is-not-a-dictionary diff --git a/tests/config/python/number.py b/tests/config/python/number.py new file mode 100644 index 0000000..fd5ac63 --- /dev/null +++ b/tests/config/python/number.py @@ -0,0 +1,5 @@ +def collect_context(_): + return { + "small": 42, + "big": 8000, + } diff --git a/tests/config/stacking/first.yaml b/tests/config/stacking/first.yaml new file mode 100644 index 0000000..6077bd8 --- /dev/null +++ b/tests/config/stacking/first.yaml @@ -0,0 +1 @@ +small_number: 7 diff --git a/tests/config/stacking/second.py b/tests/config/stacking/second.py new file mode 100644 index 0000000..f404a22 --- /dev/null +++ b/tests/config/stacking/second.py @@ -0,0 +1,2 @@ +def collect_context(current_context): + return {"big_number": current_context["small_number"] * 153} diff --git a/tests/config/static/first.json b/tests/config/static/first.json new file mode 100644 index 0000000..cb0ed06 --- /dev/null +++ b/tests/config/static/first.json @@ -0,0 +1 @@ +{"first": 1} diff --git a/tests/config/static/second.yaml b/tests/config/static/second.yaml new file mode 100644 index 0000000..0ac734c --- /dev/null +++ b/tests/config/static/second.yaml @@ -0,0 +1 @@ +second: 2 diff --git a/tests/mock_open.py b/tests/mock_open.py new file mode 100644 index 0000000..ae5acc2 --- /dev/null +++ b/tests/mock_open.py @@ -0,0 +1,97 @@ +# The MIT License (MIT) + +# Copyright (c) 2013 Ionuț Arțăriși +# 2018 Benjamin Drung + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Helper function to mock the builtin open() function + +Origin: https://github.com/mapleoin/mock_open +""" + + +import contextlib +import io +import unittest.mock + + +class NotMocked(Exception): + """Raised when a file was opened which was not mocked""" + def __init__(self, filename): + super(NotMocked, self).__init__( + "The file %s was opened, but not mocked." % filename) + self.filename = filename + + +@contextlib.contextmanager +def mock_open(filename, contents=None, exception=None, complain=True): + """Mock the open() builtin function on a specific filename + + Let execution pass through to open() on files different than + :filename:. Return a StringIO with :contents: if the file was + matched. If the :contents: parameter is not given or if it is None, + a StringIO instance simulating an empty file is returned. + + If :exception: is defined, this Exception will be raised when + open is called instead of returning the :contents:. + + If :complain: is True (default), will raise an AssertionError if + :filename: was not opened in the enclosed block. A NotMocked + exception will be raised if open() was called with a file that was + not mocked by mock_open. + + """ + open_files = set() + + def mock_file(*args): + """Mocked open() function + + Takes the same arguments as the open() function. + + """ + if args[0] == filename: + if exception is None: + file_ = io.StringIO(contents) + file_.name = filename + else: + raise exception # false positive; pylint: disable=raising-bad-type + else: + mocked_file.stop() + file_ = open(*args) + mocked_file.start() + open_files.add(file_.name) + return file_ + + mocked_file = unittest.mock.patch('builtins.open', mock_file) + mocked_file.start() + try: + yield + except NotMocked as error: + if error.filename != filename: + raise + mocked_file.stop() + try: + open_files.remove(filename) + except KeyError: + if complain: + raise AssertionError("The file %s was not opened." % filename) + for f_name in open_files: + if complain: + raise NotMocked(f_name) diff --git a/tests/pylint.conf b/tests/pylint.conf new file mode 100644 index 0000000..709bb5b --- /dev/null +++ b/tests/pylint.conf @@ -0,0 +1,30 @@ +[MASTER] + +# Pickle collected data for later comparisons. +persistent=no + + +[MESSAGES CONTROL] + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=fixme,locally-enabled,locally-disabled + + +[REPORTS] + +# Tells whether to display a full report or only the messages +reports=no + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=99 diff --git a/tests/template/attribution-error/error.jinja b/tests/template/attribution-error/error.jinja new file mode 100644 index 0000000..e9a6c54 --- /dev/null +++ b/tests/template/attribution-error/error.jinja @@ -0,0 +1,4 @@ +{% set mapping = {1: "one"} %} +{% for number, string in mapping.items()|dictsort %} +A number: {{ string }} +{% endfor %} diff --git a/tests/template/function/Document.jinja b/tests/template/function/Document.jinja new file mode 100644 index 0000000..cf4d02c --- /dev/null +++ b/tests/template/function/Document.jinja @@ -0,0 +1 @@ +The answer to the Ultimate Question of Life, The Universe, and Everything is {{ answer_to_all_questions() }}. diff --git a/tests/template/invalid/invalid.jinja b/tests/template/invalid/invalid.jinja new file mode 100644 index 0000000..7292b93 --- /dev/null +++ b/tests/template/invalid/invalid.jinja @@ -0,0 +1,2 @@ +# Cause a loading failure! +{{ diff --git a/tests/template/static/counting.jinja b/tests/template/static/counting.jinja new file mode 100644 index 0000000..87711d2 --- /dev/null +++ b/tests/template/static/counting.jinja @@ -0,0 +1,4 @@ +Counting: +* {{ first }} +* {{ second }} +* 3 diff --git a/tests/test_flake8.py b/tests/test_flake8.py new file mode 100644 index 0000000..63f65e1 --- /dev/null +++ b/tests/test_flake8.py @@ -0,0 +1,51 @@ +# Copyright (C) 2017-2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""test_flake8.py - Run flake8 check""" + +import subprocess +import sys +import unittest + +from . import get_source_files, unittest_verbosity + + +class Flake8TestCase(unittest.TestCase): + """ + This unittest class provides a test that runs the flake8 code + checker (which combines pycodestyle and pyflakes) on the Python + source code. The list of source files is provided by the + get_source_files() function. + """ + + def test_flake8(self): + """Test: Run flake8 on Python source code""" + cmd = [sys.executable, "-m", "flake8", "--max-line-length=99"] + get_source_files() + if unittest_verbosity() >= 2: + sys.stderr.write("Running following command:\n{}\n".format(" ".join(cmd))) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + + out, err = process.communicate() + if process.returncode != 0: # pragma: no cover + msgs = [] + if err: + msgs.append("flake8 exited with code {} and has unexpected output on stderr:\n{}" + .format(process.returncode, err.decode().rstrip())) + if out: + msgs.append("flake8 found issues:\n{}".format(out.decode().rstrip())) + if not msgs: + msgs.append("flake8 exited with code {} and has no output on stdout or stderr." + .format(process.returncode)) + self.fail("\n".join(msgs)) diff --git a/tests/test_ionit.py b/tests/test_ionit.py new file mode 100644 index 0000000..eb41ecb --- /dev/null +++ b/tests/test_ionit.py @@ -0,0 +1,231 @@ +# Copyright (C) 2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +"""Test ionit""" + +import os +import re +import unittest + +from ionit import collect_context, main, render_templates + +from .mock_open import mock_open + +TESTS_DIR = os.path.abspath(os.path.dirname(__file__)) +CONFIG_DIR = os.path.join(TESTS_DIR, "config") +TEMPLATE_DIR = os.path.join(TESTS_DIR, "template") + + +class TestCollectContext(unittest.TestCase): + """ + This unittest class tests collecting the context. + """ + + def test_collect_function(self): + """Test: Run collect_context("tests/config/function")""" + failures, context = collect_context(os.path.join(CONFIG_DIR, "function")) + self.assertEqual(failures, 0) + self.assertEqual(set(context.keys()), set(["answer_to_all_questions"])) + self.assertEqual(context["answer_to_all_questions"](), 42) + + def test_collect_static_context(self): + """Test: Run collect_context("tests/config/static")""" + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "static")), (0, { + "first": 1, + "second": 2, + })) + + def test_context_stacking(self): + """Test: Run collect_context("tests/config/stacking")""" + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "stacking")), (0, { + "big_number": 1071, + "small_number": 7, + })) + + def test_empty_python_file(self): + """Test: Run collect_context("tests/config/empty")""" + with self.assertLogs("ionit", level="WARNING") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "empty")), (0, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + "WARNING:ionit:Python module '[^']+config/empty/empty.py' does " + "neither define a collect_context function, nor export functions " + r"\(using the ionit_plugin.function decorator\).")) + + def test_ignoring_additional_files(self): + """Test: Run collect_context("tests/config/additional-file")""" + with self.assertLogs("ionit", level="INFO") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "additional-file")), + (0, {"key": "value"})) + self.assertEqual(len(context_manager.output), 2) + self.assertRegex(context_manager.output[0], ( + "INFO:ionit:Skipping configuration file '[^']*config/additional-file/echo', " + "because it does not end with .*")) + + def test_invalid_json(self): + """Test: Run collect_context("tests/config/invalid-json")""" + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "invalid-json")), (1, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + "ERROR:ionit:Failed to read JSON from '[^']*config/invalid-json/invalid.json': " + r"Expecting property name enclosed in double quotes: line 3 column 1 \(char 22\)")) + + def test_invalid_python(self): + """Test: Run collect_context("tests/config/invalid-python")""" + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "invalid-python")), (1, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], re.compile( + "ERROR:ionit:Importing Python module '[^']*config/invalid-python/invalid.py' " + r"failed:\n.*\nValueError: invalid literal for int\(\) with base 10: 'invalid'$", + flags=re.DOTALL)) + + def test_invalid_yaml(self): + """Test: Run collect_context("tests/config/invalid-yaml")""" + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "invalid-yaml")), (1, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + "ERROR:ionit:Failed to read YAML from '[^']*config/invalid-yaml/invalid.yaml': " + r"mapping values are not allowed here\s+" + r"in \"\S*config/invalid-yaml/invalid.yaml\", line 1, column 14")) + + def test_missing_directory(self): + """Test: Non-existing context directory""" + with self.assertLogs("ionit", level="WARNING") as context_manager: + self.assertEqual(collect_context(os.path.join(TESTS_DIR, "non-existing-directory")), + (0, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + r"WARNING:ionit:Failed to read configuration directory: \[Errno 2\] " + r"No such file or directory: '\S*non-existing-directory'")) + + def test_non_dict_context(self): + """Test failure for collect_context("tests/config/non-dict")""" + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "non-dict")), (1, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + "ERROR:ionit:Failed to update context with content from " + r"'\S*config/non-dict/invalid.yaml': dictionary update sequence " + "element #0 has length 1; 2 is required")) + + def test_python_module(self): + """Test: Run collect_context("tests/config/python")""" + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "python")), (0, { + "small": 42, + "big": 8000, + })) + + def test_raise_exception(self): + """Test failure for collect_context("tests/config/exception")""" + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(collect_context(os.path.join(CONFIG_DIR, "exception")), (1, {})) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], re.compile( + r"ERROR:ionit:Calling collect_context\(\) from '\S*config/exception/exception.py' " + "failed:\n.*\nException: Oops.$", flags=re.DOTALL)) + + +class TestRendering(unittest.TestCase): + """ + This unittest class tests rendering the templates. + """ + + def test_attribution_error(self): + """Test: Run render_templates("tests/template/attribution-error")""" + template_dir = os.path.join(TEMPLATE_DIR, "attribution-error") + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(render_templates(template_dir, {}, "jinja"), 1) + self.assertFalse(os.path.exists(os.path.join(template_dir, "error"))) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], re.compile( + r"^ERROR:ionit:Failed to render '\S*template/attribution-error/error.jinja':\n.*\n" + "AttributeError: 'dict_items' object has no attribute 'items'$", flags=re.DOTALL)) + + def test_missing_context(self): + """Test: Missing context for render_templates("tests/template/static")""" + template_dir = os.path.join(TEMPLATE_DIR, "static") + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(render_templates(template_dir, {"second": "B"}, "jinja"), 1) + self.assertFalse(os.path.exists(os.path.join(template_dir, "counting"))) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], re.compile( + r"^ERROR:ionit:Failed to render '\S*template/static/counting.jinja':\n.*\n" + "jinja2.exceptions.UndefinedError: 'first' is undefined$", flags=re.DOTALL)) + + def test_render_function(self): + """Test: Run render_templates("tests/template/function")""" + template_dir = os.path.join(TEMPLATE_DIR, "function") + try: + context = {"answer_to_all_questions": lambda: 42} + self.assertEqual(render_templates(template_dir, context, "jinja"), 0) + with open(os.path.join(template_dir, "Document")) as document_file: + self.assertEqual(document_file.read(), ( + "The answer to the Ultimate Question of Life, The Universe, " + "and Everything is 42.\n")) + finally: + os.remove(os.path.join(template_dir, "Document")) + + def test_render_invalid(self): + """Test: Run render_templates("tests/template/invalid")""" + template_dir = os.path.join(TEMPLATE_DIR, "invalid") + with self.assertLogs("ionit", level="ERROR") as context_manager: + self.assertEqual(render_templates(template_dir, {}, "jinja"), 1) + self.assertFalse(os.path.exists(os.path.join(template_dir, "invalid"))) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], re.compile( + r"ERROR:ionit:Failed to load template '\S*template/invalid/invalid.jinja':\n.*\n" + "jinja2.exceptions.TemplateSyntaxError: unexpected 'end of template'$", + flags=re.DOTALL)) + + def test_render_static(self): + """Test: Run render_templates("tests/template/static")""" + template_dir = os.path.join(TEMPLATE_DIR, "static") + try: + context = {"first": "A", "second": "B"} + self.assertEqual(render_templates(template_dir, context, "jinja"), 0) + with open(os.path.join(template_dir, "counting")) as counting_file: + self.assertEqual(counting_file.read(), "Counting:\n* A\n* B\n* 3\n") + finally: + os.remove(os.path.join(template_dir, "counting")) + + def test_render_write_protected(self): + """Test: Run render_templates("tests/template/static"), but write protected""" + template_dir = os.path.join(TEMPLATE_DIR, "static") + context = {"first": "A", "second": "B"} + with self.assertLogs("ionit", level="ERROR") as context_manager: + with mock_open(os.path.join(template_dir, "counting"), + exception=PermissionError(13, "Permission denied"), complain=False): + self.assertEqual(render_templates(template_dir, context, "jinja"), 1) + self.assertFalse(os.path.exists(os.path.join(template_dir, "counting"))) + self.assertEqual(len(context_manager.output), 1) + self.assertRegex(context_manager.output[0], ( + r"ERROR:ionit:Failed to write rendered template to '\S*template/static/counting': " + r"\[Errno 13\] Permission denied")) + + +class TestMain(unittest.TestCase): + """Test main function""" + def test_main_static(self): + """Test main() with static context""" + template_dir = os.path.join(TEMPLATE_DIR, "static") + try: + self.assertEqual(main(["-c", os.path.join(TESTS_DIR, "config/static"), "-t", + template_dir]), 0) + with open(os.path.join(template_dir, "counting")) as counting_file: + self.assertEqual(counting_file.read(), "Counting:\n* 1\n* 2\n* 3\n") + finally: + os.remove(os.path.join(template_dir, "counting")) diff --git a/tests/test_mock_open.py b/tests/test_mock_open.py new file mode 100644 index 0000000..3d855bf --- /dev/null +++ b/tests/test_mock_open.py @@ -0,0 +1,75 @@ +# The MIT License (MIT) + +# Copyright (c) 2013 Ionuț Arțăriși + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Test mock_open() + +Origin: https://github.com/mapleoin/mock_open +""" + +import collections +import unittest +import unittest.mock + +from .mock_open import NotMocked, mock_open + + +class OrderedSet(collections.UserList): # pylint: disable=too-many-ancestors + """set subclass that remembers the order entries were added""" + def add(self, element): + """Add given element to set""" + if element not in self.data: + self.data.append(element) + + +class MockTest(unittest.TestCase): + """Test mock_open()""" + def test_open_same_file_twice(self): + """Test opening the same mocked file twice""" + with mock_open("test_file", "foo"): + with open("test_file") as first: + with open("test_file") as second: + self.assertEqual(first.read(), second.read()) + first.seek(0) + self.assertEqual("foo", first.read()) + + def test_file_not_open_mocked(self): + """Test not opening the mocked file""" + with self.assertRaises(AssertionError): + with mock_open("file"): + pass + + @unittest.mock.patch("builtins.set", OrderedSet) + def test_file_open_not_mocked(self): + """Test opening a not mocked file""" + with self.assertRaises(NotMocked): + with mock_open("file1", "foo"): + with mock_open("file2", "foo"): + with open(__file__): + with open("file1") as mocked_file1: + with open("file2") as mocked_file2: + self.assertEqual(mocked_file1.read(), mocked_file2.read()) + + def test_raise_exception(self): + """Test raising an exception on mocked open()""" + with self.assertRaises(PermissionError): + with mock_open("file", exception=PermissionError(13, "Permission denied")): + open("file") diff --git a/tests/test_pylint.py b/tests/test_pylint.py new file mode 100644 index 0000000..ac08fda --- /dev/null +++ b/tests/test_pylint.py @@ -0,0 +1,68 @@ +# Copyright (C) 2010, Stefano Rivera +# Copyright (C) 2017-2018, Benjamin Drung +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +"""test_pylint.py - Run pylint""" + +import os +import re +import subprocess +import sys +import unittest + +from . import get_source_files, unittest_verbosity + +CONFIG = os.path.join(os.path.dirname(__file__), "pylint.conf") + + +class PylintTestCase(unittest.TestCase): + """ + This unittest class provides a test that runs the pylint code check + on the Python source code. The list of source files is provided by + the get_source_files() function and pylint is purely configured via + a config file. + """ + + def test_pylint(self): + """Test: Run pylint on Python source code""" + + cmd = [sys.executable, "-m", "pylint", "--rcfile=" + CONFIG, "--"] + get_source_files() + if unittest_verbosity() >= 2: + sys.stderr.write("Running following command:\n{}\n".format(" ".join(cmd))) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + close_fds=True) + out, err = process.communicate() + + if process.returncode != 0: # pragma: no cover + # Strip trailing summary (introduced in pylint 1.7). This summary might look like: + # + # ------------------------------------ + # Your code has been rated at 10.00/10 + # + out = re.sub("^(-+|Your code has been rated at .*)$", "", out.decode(), + flags=re.MULTILINE).rstrip() + + # Strip logging of used config file (introduced in pylint 1.8) + err = re.sub("^Using config file .*\n", "", err.decode()).rstrip() + + msgs = [] + if err: + msgs.append("pylint exited with code {} and has unexpected output on stderr:\n{}" + .format(process.returncode, err)) + if out: + msgs.append("pylint found issues:\n{}".format(out)) + if not msgs: + msgs.append("pylint exited with code {} and has no output on stdout or stderr." + .format(process.returncode)) + self.fail("\n".join(msgs))