From f5b1b3c6f0657cb793d34974efa1d54a15bf8d75 Mon Sep 17 00:00:00 2001 From: Alexei Znamensky <103110+russoz@users.noreply.github.com> Date: Tue, 26 Apr 2022 08:12:00 +1200 Subject: [PATCH] Command Runner (#4476) * initial commit, passing unit tests * passing one very silly integration test * multiple changes: - updated copyright year - cmd_runner - added fmt_optval - created specific exceptions - fixed bug in context class where values from module params were not being used for resolving cmd arguments - changed order of class declaration for readability purpose - tests - minor improvements in integration test code - removed some extraneous code in msimple.yml - minor improvements in unit tests - added few missing cases to unit test * multiple changes cmd_runner.py - renamed InvalidParameterName to MissingArgumentFormat - improved exception parameters - added repr and str to all exceptions - added unpacking decorator for fmt functions - CmdRunner - improved parameter validation - _CmdRunnerContext - Context runs must now pass named arguments - Simplified passing of additional arguments to module.run_command() - Provided multiple context variables with info about the run Integration tests - rename msimple.py to cmd_echo.py for clarity - added more test cases * cmd_runner: env update can be passed to runner * adding runner context info to output * added comment on OrderedDict * wrong variable * refactored all fmt functions into static methods of a class Imports should be simpler now, only one object fmt, with attr access to all callables * added unit tests for CmdRunner * fixed sanity checks * fixed mock imports * added more unit tests for CmdRunner * terminology consistency * multiple adjustments: - remove extraneous imports - renamed some variables - added wrapper around arg formatters to handle individual arg ignore_none behaviour * removed old code commented out in test * multiple changes: - ensure fmt functions return list of strings - renamed fmt parameter from `option` to `args` - renamed fmt.mapped to fmt.as_map - simplified fmt.as_map - added tests for fmt.as_fixed * more improvements in formats * fixed sanity * args_order can be a string (to be split()) and improved integration test * simplified integration test * removed overkill str() on values - run_command does that for us * as_list makes more sense than as_str in that context * added changelog fragment * Update plugins/module_utils/cmd_runner.py Co-authored-by: Felix Fontein * adjusted __repr__ output for the exceptions * added superclass object to classes * added additional comment on the testcase sample/example * suggestion from PR Co-authored-by: Felix Fontein --- changelogs/fragments/4476-cmd_runner.yml | 2 + plugins/module_utils/cmd_runner.py | 291 +++++++++++++++++ tests/integration/targets/cmd_runner/aliases | 1 + .../targets/cmd_runner/library/cmd_echo.py | 78 +++++ .../targets/cmd_runner/tasks/main.yml | 7 + .../cmd_runner/tasks/test_cmd_echo.yml | 13 + .../targets/cmd_runner/vars/main.yml | 84 +++++ .../plugins/module_utils/test_cmd_runner.py | 306 ++++++++++++++++++ 8 files changed, 782 insertions(+) create mode 100644 changelogs/fragments/4476-cmd_runner.yml create mode 100644 plugins/module_utils/cmd_runner.py create mode 100644 tests/integration/targets/cmd_runner/aliases create mode 100644 tests/integration/targets/cmd_runner/library/cmd_echo.py create mode 100644 tests/integration/targets/cmd_runner/tasks/main.yml create mode 100644 tests/integration/targets/cmd_runner/tasks/test_cmd_echo.yml create mode 100644 tests/integration/targets/cmd_runner/vars/main.yml create mode 100644 tests/unit/plugins/module_utils/test_cmd_runner.py diff --git a/changelogs/fragments/4476-cmd_runner.yml b/changelogs/fragments/4476-cmd_runner.yml new file mode 100644 index 0000000000..b995e43ac2 --- /dev/null +++ b/changelogs/fragments/4476-cmd_runner.yml @@ -0,0 +1,2 @@ +minor_changes: + - cmd_runner module util - reusable command runner with consistent argument formatting and sensible defaults (https://github.com/ansible-collections/community.general/pull/4476). diff --git a/plugins/module_utils/cmd_runner.py b/plugins/module_utils/cmd_runner.py new file mode 100644 index 0000000000..8048ed25ca --- /dev/null +++ b/plugins/module_utils/cmd_runner.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# (c) 2022, Alexei Znamensky +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from functools import wraps + +from ansible.module_utils.common.collections import is_sequence +from ansible.module_utils.six import iteritems + + +def _ensure_list(value): + return list(value) if is_sequence(value) else [value] + + +def _process_as_is(rc, out, err): + return rc, out, err + + +class CmdRunnerException(Exception): + pass + + +class MissingArgumentFormat(CmdRunnerException): + def __init__(self, arg, args_order, args_formats): + self.args_order = args_order + self.arg = arg + self.args_formats = args_formats + + def __repr__(self): + return "MissingArgumentFormat({0!r}, {1!r}, {2!r})".format( + self.arg, + self.args_order, + self.args_formats, + ) + + def __str__(self): + return "Cannot find format for parameter {0} {1} in: {2}".format( + self.arg, + self.args_order, + self.args_formats, + ) + + +class MissingArgumentValue(CmdRunnerException): + def __init__(self, args_order, arg): + self.args_order = args_order + self.arg = arg + + def __repr__(self): + return "MissingArgumentValue({0!r}, {1!r})".format( + self.args_order, + self.arg, + ) + + def __str__(self): + return "Cannot find value for parameter {0} in {1}".format( + self.arg, + self.args_order, + ) + + +class FormatError(CmdRunnerException): + def __init__(self, name, value, args_formats, exc): + self.name = name + self.value = value + self.args_formats = args_formats + self.exc = exc + super(FormatError, self).__init__() + + def __repr__(self): + return "FormatError({0!r}, {1!r}, {2!r}, {3!r})".format( + self.name, + self.value, + self.args_formats, + self.exc, + ) + + def __str__(self): + return "Failed to format parameter {0} with value {1}: {2}".format( + self.name, + self.value, + self.exc, + ) + + +class _ArgFormat(object): + def __init__(self, func, ignore_none=None): + self.func = func + self.ignore_none = ignore_none + + def __call__(self, value, ctx_ignore_none): + ignore_none = self.ignore_none if self.ignore_none is not None else ctx_ignore_none + if value is None and ignore_none: + return [] + f = self.func + return [str(x) for x in f(value)] + + +class _Format(object): + @staticmethod + def as_bool(args): + return _ArgFormat(lambda value: _ensure_list(args) if value else []) + + @staticmethod + def as_bool_not(args): + return _ArgFormat(lambda value: [] if value else _ensure_list(args), ignore_none=False) + + @staticmethod + def as_optval(arg, ignore_none=None): + return _ArgFormat(lambda value: ["{0}{1}".format(arg, value)], ignore_none=ignore_none) + + @staticmethod + def as_opt_val(arg, ignore_none=None): + return _ArgFormat(lambda value: [arg, value], ignore_none=ignore_none) + + @staticmethod + def as_opt_eq_val(arg, ignore_none=None): + return _ArgFormat(lambda value: ["{0}={1}".format(arg, value)], ignore_none=ignore_none) + + @staticmethod + def as_list(ignore_none=None): + return _ArgFormat(_ensure_list, ignore_none=ignore_none) + + @staticmethod + def as_fixed(args): + return _ArgFormat(lambda value: _ensure_list(args), ignore_none=False) + + @staticmethod + def as_func(func, ignore_none=None): + return _ArgFormat(func, ignore_none=ignore_none) + + @staticmethod + def as_map(_map, default=None, ignore_none=None): + return _ArgFormat(lambda value: _ensure_list(_map.get(value, default)), ignore_none=ignore_none) + + @staticmethod + def as_default_type(_type, arg="", ignore_none=None): + fmt = _Format + if _type == "dict": + return fmt.as_func(lambda d: ["--{0}={1}".format(*a) for a in iteritems(d)], + ignore_none=ignore_none) + if _type == "list": + return fmt.as_func(lambda value: ["--{0}".format(x) for x in value], ignore_none=ignore_none) + if _type == "bool": + return fmt.as_bool("--{0}".format(arg)) + + return fmt.as_opt_val("--{0}".format(arg), ignore_none=ignore_none) + + @staticmethod + def unpack_args(func): + @wraps(func) + def wrapper(v): + return func(*v) + return wrapper + + @staticmethod + def unpack_kwargs(func): + @wraps(func) + def wrapper(v): + return func(**v) + return wrapper + + +class CmdRunner(object): + """ + Wrapper for ``AnsibleModule.run_command()``. + + It aims to provide a reusable runner with consistent argument formatting + and sensible defaults. + """ + + @staticmethod + def _prepare_args_order(order): + return tuple(order) if is_sequence(order) else tuple(order.split()) + + def __init__(self, module, command, arg_formats=None, default_args_order=(), + check_rc=False, force_lang="C", path_prefix=None, environ_update=None): + self.module = module + self.command = _ensure_list(command) + self.default_args_order = self._prepare_args_order(default_args_order) + if arg_formats is None: + arg_formats = {} + self.arg_formats = dict(arg_formats) + self.check_rc = check_rc + self.force_lang = force_lang + self.path_prefix = path_prefix + if environ_update is None: + environ_update = {} + self.environ_update = environ_update + + self.command[0] = module.get_bin_path(command[0], opt_dirs=path_prefix, required=True) + + for mod_param_name, spec in iteritems(module.argument_spec): + if mod_param_name not in self.arg_formats: + self.arg_formats[mod_param_name] = _Format.as_default_type(spec['type'], mod_param_name) + + def context(self, args_order=None, output_process=None, ignore_value_none=True, **kwargs): + if output_process is None: + output_process = _process_as_is + if args_order is None: + args_order = self.default_args_order + args_order = self._prepare_args_order(args_order) + for p in args_order: + if p not in self.arg_formats: + raise MissingArgumentFormat(p, args_order, tuple(self.arg_formats.keys())) + return _CmdRunnerContext(runner=self, + args_order=args_order, + output_process=output_process, + ignore_value_none=ignore_value_none, **kwargs) + + def has_arg_format(self, arg): + return arg in self.arg_formats + + +class _CmdRunnerContext(object): + def __init__(self, runner, args_order, output_process, ignore_value_none, **kwargs): + self.runner = runner + self.args_order = tuple(args_order) + self.output_process = output_process + self.ignore_value_none = ignore_value_none + self.run_command_args = dict(kwargs) + + self.environ_update = runner.environ_update + self.environ_update.update(self.run_command_args.get('environ_update', {})) + if runner.force_lang: + self.environ_update.update({ + 'LANGUAGE': runner.force_lang, + 'LC_ALL': runner.force_lang, + }) + self.run_command_args['environ_update'] = self.environ_update + + if 'check_rc' not in self.run_command_args: + self.run_command_args['check_rc'] = runner.check_rc + self.check_rc = self.run_command_args['check_rc'] + + self.cmd = None + self.results_rc = None + self.results_out = None + self.results_err = None + self.results_processed = None + + def run(self, **kwargs): + runner = self.runner + module = self.runner.module + self.cmd = list(runner.command) + self.context_run_args = dict(kwargs) + + named_args = dict(module.params) + named_args.update(kwargs) + for arg_name in self.args_order: + value = None + try: + value = named_args[arg_name] + self.cmd.extend(runner.arg_formats[arg_name](value, ctx_ignore_none=self.ignore_value_none)) + except KeyError: + raise MissingArgumentValue(self.args_order, arg_name) + except Exception as e: + raise FormatError(arg_name, value, runner.arg_formats[arg_name], e) + + results = module.run_command(self.cmd, **self.run_command_args) + self.results_rc, self.results_out, self.results_err = results + self.results_processed = self.output_process(*results) + return self.results_processed + + @property + def run_info(self): + return dict( + ignore_value_none=self.ignore_value_none, + check_rc=self.check_rc, + environ_update=self.environ_update, + args_order=self.args_order, + cmd=self.cmd, + run_command_args=self.run_command_args, + context_run_args=self.context_run_args, + results_rc=self.results_rc, + results_out=self.results_out, + results_err=self.results_err, + results_processed=self.results_processed, + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + +fmt = _Format() diff --git a/tests/integration/targets/cmd_runner/aliases b/tests/integration/targets/cmd_runner/aliases new file mode 100644 index 0000000000..765b70da79 --- /dev/null +++ b/tests/integration/targets/cmd_runner/aliases @@ -0,0 +1 @@ +shippable/posix/group2 diff --git a/tests/integration/targets/cmd_runner/library/cmd_echo.py b/tests/integration/targets/cmd_runner/library/cmd_echo.py new file mode 100644 index 0000000000..842df7e131 --- /dev/null +++ b/tests/integration/targets/cmd_runner/library/cmd_echo.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (c) 2022, Alexei Znamensky +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import sys + +DOCUMENTATION = ''' +module: cmd_echo +author: "Alexei Znamensky (@russoz)" +short_description: Simple module for testing +description: + - Simple module test description. +options: + command: + description: aaa + type: list + elements: str + required: true + arg_formats: + description: bbb + type: dict + required: true + arg_order: + description: ccc + type: raw + required: true + arg_values: + description: ddd + type: list + required: true + aa: + description: eee + type: raw +''' + +EXAMPLES = "" + +RETURN = "" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, fmt + + +def main(): + module = AnsibleModule( + argument_spec=dict( + arg_formats=dict(type="dict", default={}), + arg_order=dict(type="raw", required=True), + arg_values=dict(type="dict", default={}), + aa=dict(type="raw"), + ), + ) + p = module.params + + arg_formats = {} + for arg, fmt_spec in p['arg_formats'].items(): + func = getattr(fmt, fmt_spec['func']) + args = fmt_spec.get("args", []) + + arg_formats[arg] = func(*args) + + runner = CmdRunner(module, ['echo', '--'], arg_formats=arg_formats) + + info = None + with runner.context(p['arg_order']) as ctx: + result = ctx.run(**p['arg_values']) + info = ctx.run_info + rc, out, err = result + + module.exit_json(rc=rc, out=out, err=err, info=info) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/cmd_runner/tasks/main.yml b/tests/integration/targets/cmd_runner/tasks/main.yml new file mode 100644 index 0000000000..72cf506c6a --- /dev/null +++ b/tests/integration/targets/cmd_runner/tasks/main.yml @@ -0,0 +1,7 @@ +# (c) 2022, Alexei Znamensky +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: parameterized test cmd_echo + ansible.builtin.include_tasks: + file: test_cmd_echo.yml + loop: "{{ cmd_echo_tests }}" diff --git a/tests/integration/targets/cmd_runner/tasks/test_cmd_echo.yml b/tests/integration/targets/cmd_runner/tasks/test_cmd_echo.yml new file mode 100644 index 0000000000..d00a468c04 --- /dev/null +++ b/tests/integration/targets/cmd_runner/tasks/test_cmd_echo.yml @@ -0,0 +1,13 @@ +--- +- name: test cmd_echo [{{ item.name }}] + cmd_echo: + arg_formats: "{{ item.arg_formats|default(omit) }}" + arg_order: "{{ item.arg_order }}" + arg_values: "{{ item.arg_values|default(omit) }}" + aa: "{{ item.aa|default(omit) }}" + register: test_result + ignore_errors: "{{ item.expect_error|default(omit) }}" + +- name: check results [{{ item.name }}] + assert: + that: "{{ item.assertions }}" diff --git a/tests/integration/targets/cmd_runner/vars/main.yml b/tests/integration/targets/cmd_runner/vars/main.yml new file mode 100644 index 0000000000..ade4646a15 --- /dev/null +++ b/tests/integration/targets/cmd_runner/vars/main.yml @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# (c) 2022, Alexei Znamensky +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +cmd_echo_tests: + - name: set aa and bb value + arg_formats: + aa: + func: as_opt_eq_val + args: [--answer] + bb: + func: as_bool + args: [--bb-here] + arg_order: 'aa bb' + arg_values: + bb: true + aa: 11 + assertions: + - test_result.rc == 0 + - test_result.out == "-- --answer=11 --bb-here\n" + - test_result.err == "" + + - name: default aa value + arg_formats: + aa: + func: as_opt_eq_val + args: [--answer] + bb: + func: as_bool + args: [--bb-here] + arg_order: ['aa', 'bb'] + arg_values: + aa: 43 + bb: true + assertions: + - test_result.rc == 0 + - test_result.out == "-- --answer=43 --bb-here\n" + - test_result.err == "" + + - name: implicit aa format + arg_formats: + bb: + func: as_bool + args: [--bb-here] + arg_order: ['aa', 'bb'] + arg_values: + bb: true + aa: 1984 + assertions: + - test_result.rc == 0 + - test_result.out == "-- --aa 1984 --bb-here\n" + - test_result.err == "" + + - name: missing bb format + arg_order: ['aa', 'bb'] + arg_values: + bb: true + aa: 1984 + expect_error: true + assertions: + - test_result is failed + - test_result.rc == 1 + - '"out" not in test_result' + - '"err" not in test_result' + - >- + "MissingArgumentFormat: Cannot find format for parameter bb" + in test_result.module_stderr + + - name: missing bb value + arg_formats: + bb: + func: as_bool + args: [--bb-here] + arg_order: 'aa bb' + aa: 1984 + expect_error: true + assertions: + - test_result is failed + - test_result.rc == 1 + - '"out" not in test_result' + - '"err" not in test_result' + - >- + "MissingArgumentValue: Cannot find value for parameter bb" + in test_result.module_stderr diff --git a/tests/unit/plugins/module_utils/test_cmd_runner.py b/tests/unit/plugins/module_utils/test_cmd_runner.py new file mode 100644 index 0000000000..b5145ec815 --- /dev/null +++ b/tests/unit/plugins/module_utils/test_cmd_runner.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# (c) 2022, Alexei Znamensky +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from sys import version_info + +import pytest + +from ansible_collections.community.general.tests.unit.compat.mock import MagicMock, PropertyMock +from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, fmt + + +TC_FORMATS = dict( + simple_boolean__true=(fmt.as_bool, ("--superflag",), True, ["--superflag"]), + simple_boolean__false=(fmt.as_bool, ("--superflag",), False, []), + simple_boolean__none=(fmt.as_bool, ("--superflag",), None, []), + simple_boolean_not__true=(fmt.as_bool_not, ("--superflag",), True, []), + simple_boolean_not__false=(fmt.as_bool_not, ("--superflag",), False, ["--superflag"]), + simple_boolean_not__none=(fmt.as_bool_not, ("--superflag",), None, ["--superflag"]), + simple_optval__str=(fmt.as_optval, ("-t",), "potatoes", ["-tpotatoes"]), + simple_optval__int=(fmt.as_optval, ("-t",), 42, ["-t42"]), + simple_opt_val__str=(fmt.as_opt_val, ("-t",), "potatoes", ["-t", "potatoes"]), + simple_opt_val__int=(fmt.as_opt_val, ("-t",), 42, ["-t", "42"]), + simple_opt_eq_val__str=(fmt.as_opt_eq_val, ("--food",), "potatoes", ["--food=potatoes"]), + simple_opt_eq_val__int=(fmt.as_opt_eq_val, ("--answer",), 42, ["--answer=42"]), + simple_list_potato=(fmt.as_list, (), "literal_potato", ["literal_potato"]), + simple_list_42=(fmt.as_list, (), 42, ["42"]), + simple_map=(fmt.as_map, ({'a': 1, 'b': 2, 'c': 3},), 'b', ["2"]), + simple_default_type__list=(fmt.as_default_type, ("list",), [1, 2, 3, 5, 8], ["--1", "--2", "--3", "--5", "--8"]), + simple_default_type__bool_true=(fmt.as_default_type, ("bool", "what"), True, ["--what"]), + simple_default_type__bool_false=(fmt.as_default_type, ("bool", "what"), False, []), + simple_default_type__potato=(fmt.as_default_type, ("any-other-type", "potato"), "42", ["--potato", "42"]), + simple_fixed_true=(fmt.as_fixed, [("--always-here", "--forever")], True, ["--always-here", "--forever"]), + simple_fixed_false=(fmt.as_fixed, [("--always-here", "--forever")], False, ["--always-here", "--forever"]), + simple_fixed_none=(fmt.as_fixed, [("--always-here", "--forever")], None, ["--always-here", "--forever"]), + simple_fixed_str=(fmt.as_fixed, [("--always-here", "--forever")], "something", ["--always-here", "--forever"]), +) +if tuple(version_info) >= (3, 1): + from collections import OrderedDict + + # needs OrderedDict to provide a consistent key order + TC_FORMATS["simple_default_type__dict"] = ( # type: ignore + fmt.as_default_type, + ("dict",), + OrderedDict((('a', 1), ('b', 2))), + ["--a=1", "--b=2"] + ) +TC_FORMATS_IDS = sorted(TC_FORMATS.keys()) + + +@pytest.mark.parametrize('func, fmt_opt, value, expected', + (TC_FORMATS[tc] for tc in TC_FORMATS_IDS), + ids=TC_FORMATS_IDS) +def test_arg_format(func, fmt_opt, value, expected): + fmt_func = func(*fmt_opt) + actual = fmt_func(value, ctx_ignore_none=True) + print("formatted string = {0}".format(actual)) + assert actual == expected, "actual = {0}".format(actual) + + +TC_RUNNER = dict( + # SAMPLE: This shows all possible elements of a test case. It does not actually run. + # + # testcase_name=( + # # input + # dict( + # args_bundle = dict( + # param1=dict( + # type="int", + # value=11, + # fmt_func=fmt.as_opt_eq_val, + # fmt_arg="--answer", + # ), + # param2=dict( + # fmt_func=fmt.as_bool, + # fmt_arg="--bb-here", + # ) + # ), + # runner_init_args = dict( + # command="testing", + # default_args_order=(), + # check_rc=False, + # force_lang="C", + # path_prefix=None, + # environ_update=None, + # ), + # runner_ctx_args = dict( + # args_order=['aa', 'bb'], + # output_process=None, + # ignore_value_none=True, + # ), + # ), + # # command execution + # dict( + # runner_ctx_run_args = dict(bb=True), + # rc = 0, + # out = "", + # err = "", + # ), + # # expected + # dict( + # results=(), + # run_info=dict( + # cmd=['/mock/bin/testing', '--answer=11', '--bb-here'], + # environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + # ), + # exc=None, + # ), + # ), + # + aa_bb=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('aa', 'bb'), + ), + ), + ), + aa_bb_default_order=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(default_args_order=['bb', 'aa']), + runner_ctx_args=dict(), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--bb-here', '--answer=11'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('bb', 'aa'), + ), + ), + ), + aa_bb_default_order_args_order=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(default_args_order=['bb', 'aa']), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('aa', 'bb'), + ), + ), + ), + aa_bb_dup_in_args_order=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(), + runner_ctx_args=dict(args_order=['aa', 'bb', 'aa']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=11', '--bb-here', '--answer=11'], + ), + ), + ), + aa_bb_process_output=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(default_args_order=['bb', 'aa']), + runner_ctx_args=dict( + args_order=['aa', 'bb'], + output_process=lambda rc, out, err: '-/-'.join([str(rc), out, err]) + ), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="ni", err="nu"), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=11', '--bb-here'], + ), + results="0-/-ni-/-nu" + ), + ), + aa_bb_ignore_none_with_none=( + dict( + args_bundle=dict( + aa=dict(type="int", value=49, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(default_args_order=['bb', 'aa']), + runner_ctx_args=dict( + args_order=['aa', 'bb'], + ignore_value_none=True, # default + ), + ), + dict(runner_ctx_run_args=dict(bb=None), rc=0, out="ni", err="nu"), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=49'], + ), + ), + ), + aa_bb_ignore_not_none_with_none=( + dict( + args_bundle=dict( + aa=dict(type="int", value=49, fmt_func=fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(default_args_order=['bb', 'aa']), + runner_ctx_args=dict( + args_order=['aa', 'bb'], + ignore_value_none=False, + ), + ), + dict(runner_ctx_run_args=dict(aa=None, bb=True), rc=0, out="ni", err="nu"), + dict( + run_info=dict( + cmd=['/mock/bin/testing', '--answer=None', '--bb-here'], + ), + ), + ), +) +TC_RUNNER_IDS = sorted(TC_RUNNER.keys()) + + +@pytest.mark.parametrize('runner_input, cmd_execution, expected', + (TC_RUNNER[tc] for tc in TC_RUNNER_IDS), + ids=TC_RUNNER_IDS) +def test_runner(runner_input, cmd_execution, expected): + arg_spec = {} + params = {} + arg_formats = {} + for k, v in runner_input['args_bundle'].items(): + try: + arg_spec[k] = {'type': v['type']} + except KeyError: + pass + try: + params[k] = v['value'] + except KeyError: + pass + try: + arg_formats[k] = v['fmt_func'](v['fmt_arg']) + except KeyError: + pass + + orig_results = tuple(cmd_execution[x] for x in ('rc', 'out', 'err')) + + print("arg_spec={0}\nparams={1}\narg_formats={2}\n".format( + arg_spec, + params, + arg_formats, + )) + + module = MagicMock() + type(module).argument_spec = PropertyMock(return_value=arg_spec) + type(module).params = PropertyMock(return_value=params) + module.get_bin_path.return_value = '/mock/bin/testing' + module.run_command.return_value = orig_results + + runner = CmdRunner( + module=module, + command="testing", + arg_formats=arg_formats, + **runner_input['runner_init_args'] + ) + + def _assert_run_info(actual, expected): + reduced = dict((k, actual[k]) for k in expected.keys()) + assert reduced == expected, "{0}".format(reduced) + + def _assert_run(runner_input, cmd_execution, expected, ctx, results): + _assert_run_info(ctx.run_info, expected['run_info']) + assert results == expected.get('results', orig_results) + + exc = expected.get("exc") + if exc: + with pytest.raises(exc): + with runner.context(**runner_input['runner_ctx_args']) as ctx: + results = ctx.run(**cmd_execution['runner_ctx_run_args']) + _assert_run(runner_input, cmd_execution, expected, ctx, results) + + else: + with runner.context(**runner_input['runner_ctx_args']) as ctx: + results = ctx.run(**cmd_execution['runner_ctx_run_args']) + _assert_run(runner_input, cmd_execution, expected, ctx, results)