From 1f10268aa0ca0e0ed813b909b9dddf5114a520c9 Mon Sep 17 00:00:00 2001 From: yage Date: Mon, 15 Apr 2024 08:45:55 -0600 Subject: [PATCH] - updated python scwrypts API to use latest pattern established in the nodejs library --- py/lib/scwrypts/scwrypts/exceptions.py | 2 +- py/lib/scwrypts/scwrypts/scwrypts.py | 48 +++--- py/lib/scwrypts/scwrypts/test_scwrypts.py | 184 ++++++++++++++++++++++ 3 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 py/lib/scwrypts/scwrypts/test_scwrypts.py diff --git a/py/lib/scwrypts/scwrypts/exceptions.py b/py/lib/scwrypts/scwrypts/exceptions.py index 52c4fe6..46b9abb 100644 --- a/py/lib/scwrypts/scwrypts/exceptions.py +++ b/py/lib/scwrypts/scwrypts/exceptions.py @@ -13,7 +13,7 @@ class MissingFlagAndEnvironmentVariableError(EnvironmentError, ArgumentError): class MissingScwryptsExecutableError(EnvironmentError): def __init__(self): - super().__init__(f'scwrypts must be installed and available on your PATH') + super().__init__('scwrypts must be installed and available on your PATH') class BadScwryptsLookupError(ValueError): diff --git a/py/lib/scwrypts/scwrypts/scwrypts.py b/py/lib/scwrypts/scwrypts/scwrypts.py index 9348ec2..29644f5 100644 --- a/py/lib/scwrypts/scwrypts/scwrypts.py +++ b/py/lib/scwrypts/scwrypts/scwrypts.py @@ -5,44 +5,54 @@ from subprocess import run from .exceptions import MissingScwryptsExecutableError, BadScwryptsLookupError, MissingScwryptsGroupOrTypeError -def scwrypts(*args, patterns=None, name=None, group=None, _type=None, log_level=None): +def scwrypts(patterns=None, args=None, executable_args=None, name=None, group=None, _type=None): ''' top-level scwrypts invoker from python - - patterns allows for pattern-based scwrypt lookup - - name/group/type allos for precise-match lookup + patterns str / list pattern-based scwrypt lookup + args str / list arguments forwarded to the invoked scwrypt + executable_args str / list arguments for the 'scwrypts' executable + (str above assumes space-delimited values) - *args should be a list of strings and is forwarded to the - invoked scwrypt + name str exact scwrypt lookup name (requires group and _type) + group str exact scwrypt lookup group + _type str exact scwrypt lookup type + + SCWRYPTS_EXECUTABLE configuration variable which defines the full path to scwrypts executable see 'scwrypts --help' for more information ''' - executable = which('scwrypts') - if executable is None: - raise MissingScwryptsExecutableError() - if patterns is None and name is None: raise BadScwryptsLookupError() - pre_args = [] + if name is not None and (group is None or _type is None): + raise MissingScwryptsGroupOrTypeError(group, _type) - if name is None: - pre_args += patterns - else: - pre_args += ['--name', name, '--group', group, '--type', _type] - if group is None or _type is None: - raise MissingScwryptsGroupOrTypeError(group, _type) + executable = which(getenv('SCWRYPTS_EXECUTABLE', 'scwrypts')) - if log_level is not None: - pre_args += ['--log-level', log_level] + if executable is None: + raise MissingScwryptsExecutableError() + + lookup = _parse(patterns) if name is None else f'--name {name} --group {group} --type {_type}' depth = getenv('SUBSCWRYPT', '') if depth != '': depth = int(depth) + 1 return run( - f'SUBSCWRYPT={depth} {executable} {" ".join(pre_args)} -- {" ".join(args)}', + f'SUBSCWRYPT={depth} {executable} {_parse(executable_args)} {lookup} -- {_parse(args)}', shell=True, executable='/bin/zsh', check=False, + capture_output=True, + text=True, ) + +def _parse(string_or_list_args): + if string_or_list_args is None: + return '' + + if isinstance(string_or_list_args, list): + return ' '.join(string_or_list_args) + + return str(string_or_list_args) diff --git a/py/lib/scwrypts/scwrypts/test_scwrypts.py b/py/lib/scwrypts/scwrypts/test_scwrypts.py new file mode 100644 index 0000000..d828616 --- /dev/null +++ b/py/lib/scwrypts/scwrypts/test_scwrypts.py @@ -0,0 +1,184 @@ +from random import choice +from re import search +from string import ascii_letters, digits +from types import SimpleNamespace +from unittest.mock import patch + +from pytest import fixture, raises + +from scwrypts.test import get_generator + +from .exceptions import MissingScwryptsExecutableError, BadScwryptsLookupError, MissingScwryptsGroupOrTypeError +from .scwrypts import scwrypts + +##################################################################### + +def test_scwrypts(sample, _scwrypts): + assert validate_scwrypts_output(sample, _scwrypts) + +def test_scwrypts_finds_system_executable(sample, _scwrypts, mock_which): + mock_which.assert_called_once_with(sample.env['SCWRYPTS_EXECUTABLE']) + +def test_scwrypts_uses_configured_executable_path(_scwrypts, mock_getenv): + mock_getenv.assert_any_call('SCWRYPTS_EXECUTABLE', 'scwrypts') + +def test_scwrypts_uses_correct_depth(_scwrypts, mock_getenv): + mock_getenv.assert_any_call('SUBSCWRYPT', '') + +def test_scwrypts_runs_subprocess(_scwrypts, mock_run): + mock_run.assert_called_once() + +########################################## + +def test_scwrypts_omit_optionals(sample, _scwrypts_omit_optionals): + assert validate_scwrypts_output(sample, _scwrypts_omit_optionals) + +def test_scwrypts_omit_optionals_finds_system_executable(sample, _scwrypts_omit_optionals, mock_which): + mock_which.assert_called_once_with('scwrypts') + +def test_scwrypts_omit_optionals_uses_configured_executable_path(_scwrypts_omit_optionals, mock_getenv): + mock_getenv.assert_any_call('SCWRYPTS_EXECUTABLE', 'scwrypts') + +def test_scwrypts_omit_optionals_uses_correct_depth(_scwrypts_omit_optionals, mock_getenv): + mock_getenv.assert_any_call('SUBSCWRYPT', '') + +def test_scwrypts_omit_optionals_runs_subprocess(_scwrypts_omit_optionals, mock_run): + mock_run.assert_called_once() + +########################################## + +def test_invalid_lookup_missing_patterns_and_name(sample): + sample.patterns = None + sample.name = None + with raises(BadScwryptsLookupError): + scwrypts(**get_scwrypts_args(sample)) + +def test_invalid_name_lookup_missing_group(sample): + sample.group = None + with raises(MissingScwryptsGroupOrTypeError): + scwrypts(**get_scwrypts_args(sample)) + +def test_invalid_name_lookup_missing_type(sample): + sample._type = None # pylint: disable=protected-access + with raises(MissingScwryptsGroupOrTypeError): + scwrypts(**get_scwrypts_args(sample)) + +def test_invalid_scwrypts_installation(sample, mock_which): + mock_which.return_value = None + with raises(MissingScwryptsExecutableError): + scwrypts(**get_scwrypts_args(sample)) + +##################################################################### + +generate = get_generator({ + 'str_length_minimum': 8, + 'str_length_maximum': 128, + 'character_set': ascii_letters + digits + '/-_' + }) + +def _generate_str_or_list_arg(): + random_arg = generate(list, {'data_types': {str}}) + return random_arg if choice([str, list]) == list else ' '.join(random_arg) + +@fixture(name='sample') +def fixture_sample(): + sample = SimpleNamespace( + patterns = _generate_str_or_list_arg(), + args = _generate_str_or_list_arg(), + executable_args = _generate_str_or_list_arg(), + + name = generate(str), + group = generate(str), + _type = generate(str), + + executable = generate(str), + + env = { + 'SCWRYPTS_EXECUTABLE': generate(str), + 'SUBSCWRYPT': str(generate(int, {'minimum': 1, 'maximum': 99})), + }, + + returncode = generate(int), + stdout = generate(str), + stderr = generate(str), + ) + + return sample + +def get_scwrypts_args(sample): + return { + key: getattr(sample, key) + for key in [ + 'patterns', + 'args', + 'executable_args', + 'name', + 'group', + '_type', + ] + } + + +##################################################################### + +@fixture(name='mock_which', autouse=True) +def fixture_mock_which(sample): + with patch('scwrypts.scwrypts.scwrypts.which') as mock: + mock.return_value = sample.executable + yield mock + +@fixture(name='mock_getenv', autouse=True) +def fixture_mock_getenv(sample): + with patch('scwrypts.scwrypts.scwrypts.getenv') as mock: + mock.side_effect = sample.env.get + yield mock + +@fixture(name='mock_run', autouse=True) +def fixture_mock_run(sample): + with patch('scwrypts.scwrypts.scwrypts.run') as mock: + mock.side_effect = lambda *args, **_kwargs: SimpleNamespace( + args = args, + returncode = sample.returncode, + stdout = sample.stdout, + stderr = sample.stderr, + ) + yield mock + +##################################################################### + +@fixture(name='_scwrypts') +def fixture_scwrypts(sample): + return scwrypts(**get_scwrypts_args(sample)) + +@fixture(name='_scwrypts_omit_optionals') +def fixture_scwrypts_omit_optionals(sample): + sample.args = None + sample.executable_args = None + + del sample.env['SCWRYPTS_EXECUTABLE'] + del sample.env['SUBSCWRYPT'] + + return scwrypts(**get_scwrypts_args(sample)) + +def validate_scwrypts_output(sample, output): + # + # I would love to use 'assert _scwrypts == SimpleNamespace(...expected...)' + # but the output.args is difficult to recreate without copying all the + # processing logic over from the scwrypts function + # + # opting for a bit of a strange equality test here, checking the args + # as closely as possible without copying parsing logic + # + run_args_reduced_to_a_single_string = len(output.args) == 1 + run_args_follow_expected_form = search( + fr'^SUBSCWRYPT=.* {sample.executable} .*-- .*$', + output.args[0], + ) + + return all([ + run_args_reduced_to_a_single_string, + run_args_follow_expected_form, + output.returncode == sample.returncode, + output.stdout == sample.stdout, + output.stderr == sample.stderr, + ])