diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cdd2551..ff9d604 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,16 +20,6 @@ "settings": { "python.pythonPath": "/usr/local/bin/python", "python.defaultInterpreterPath": "/usr/local/bin/python", - "pylint.path": [ - "pylint" - ], - "flake8.path": [ - "flake8" - ], - "mypy-type-checker.path": [ - "mypy" - ], - "mypy-type-checker.ignorePatterns": ["tests/e2e/example/**/*.py"], "mypy.targets": [ "grizzly_cli/", "tests/" @@ -53,15 +43,13 @@ "extensions": [ "ms-python.python", "ms-python.vscode-pylance", - "ms-python.pylint", - "ms-python.flake8", "ms-python.mypy-type-checker", "editorconfig.editorconfig", - "editorconfig.editorconfig", "eamodio.gitlens", "ryanluker.vscode-coverage-gutters", "matangover.mypy", - "oderwat.indent-rainbow" + "oderwat.indent-rainbow", + "charliermarsh.ruff" ] } }, diff --git a/.editorconfig b/.editorconfig index 8ca17be..8f6152a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,6 @@ indent_size = 2 [*.feature] indent_size = 2 + +[tests/unit/utils/test___init__.py] +trim_trailing_whitespace = false diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index f33d582..01b72a4 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -33,17 +33,14 @@ jobs: id: pip run: python -m pip install .[dev] - - name: pylint - id: pylint - run: python -m pylint --jobs=0 --fail-under=10 grizzly_cli/ tests/ - name: mypy id: mypy run: python -m mypy grizzly_cli/ tests/ - - name: flake8 - id: flake8 - run: python -m flake8 + - name: ruff + id: ruff + run: python -m ruff check grizzly_cli/ tests/ test-and-coverage: name: "test-and-coverage / ${{ matrix.runs-on }} / python-${{ matrix.python-version }}" diff --git a/grizzly_cli/__init__.py b/grizzly_cli/__init__.py index 4b3c218..099e646 100644 --- a/grizzly_cli/__init__.py +++ b/grizzly_cli/__init__.py @@ -1,28 +1,31 @@ -import os +from __future__ import annotations -from typing import Callable, List, Optional +from os import environ +from pathlib import Path +from typing import TYPE_CHECKING, Callable, ClassVar, Optional -from behave.model import Scenario +from grizzly_cli.__version__ import __version__ -from .argparse import ArgumentSubParser -from .__version__ import __version__ +if TYPE_CHECKING: # pragma: no cover + from behave.model import Scenario + from grizzly_cli.argparse import ArgumentSubParser -EXECUTION_CONTEXT = os.getcwd() +EXECUTION_CONTEXT = Path.cwd().as_posix() -STATIC_CONTEXT = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'static') +STATIC_CONTEXT = Path.joinpath(Path(__file__).parent.absolute(), 'static').as_posix() -MOUNT_CONTEXT = os.environ.get('GRIZZLY_MOUNT_CONTEXT', EXECUTION_CONTEXT) +MOUNT_CONTEXT = environ.get('GRIZZLY_MOUNT_CONTEXT', EXECUTION_CONTEXT) -PROJECT_NAME = os.path.basename(EXECUTION_CONTEXT) +PROJECT_NAME = Path(EXECUTION_CONTEXT).name -SCENARIOS: List[Scenario] = [] +SCENARIOS: list[Scenario] = [] FEATURE_DESCRIPTION: Optional[str] = None class register_parser: - registered: List[Callable[[ArgumentSubParser], None]] = [] + registered: ClassVar[list[Callable[[ArgumentSubParser], None]]] = [] order: Optional[int] def __init__(self, order: Optional[int] = None) -> None: diff --git a/grizzly_cli/__main__.py b/grizzly_cli/__main__.py index 73b535e..212a8b7 100644 --- a/grizzly_cli/__main__.py +++ b/grizzly_cli/__main__.py @@ -1,19 +1,23 @@ -import argparse +from __future__ import annotations + import os import sys - +from pathlib import Path from shutil import which -from typing import Tuple, Optional, List from traceback import format_exc +from typing import TYPE_CHECKING, Optional -from .argparse import ArgumentParser -from .utils import ask_yes_no, get_distributed_system, get_dependency_versions, setup_logging -from .init import init -from .local import local -from .distributed import distributed -from .auth import auth -from .keyvault import keyvault -from . import __version__, register_parser +from grizzly_cli import __version__, register_parser +from grizzly_cli.argparse import ArgumentParser +from grizzly_cli.auth import auth +from grizzly_cli.distributed import distributed +from grizzly_cli.init import init +from grizzly_cli.keyvault import keyvault +from grizzly_cli.local import local +from grizzly_cli.utils import ask_yes_no, get_dependency_versions, get_distributed_system, setup_logging + +if TYPE_CHECKING: + import argparse def _create_parser() -> ArgumentParser: @@ -53,40 +57,65 @@ def _create_parser() -> ArgumentParser: return parser +def _parse_show_version(args: argparse.Namespace) -> None: + version = '(development)' if __version__ == '0.0.0' else __version__ + + grizzly_versions: Optional[tuple[Optional[str], Optional[list[str]]]] = None + + if args.version == 'all': + grizzly_versions, locust_version = get_dependency_versions(local_install=False) + else: + grizzly_versions, locust_version = None, None + + print(f'grizzly-cli {version}') + if grizzly_versions is not None: + grizzly_version, grizzly_extras = grizzly_versions + if grizzly_version is not None: + print(f'└── grizzly {grizzly_version}', end='') + if grizzly_extras is not None and len(grizzly_extras) > 0: + print(f' ── extras: {", ".join(grizzly_extras)}', end='') + print() + + if locust_version is not None: + print(f' └── locust {locust_version}') + + raise SystemExit(0) + + +def _parse_run(parser: ArgumentParser, args: argparse.Namespace) -> None: + if args.command == 'dist': + if args.limit_nofile < 10001 and not args.yes: + print('!! this will cause warning messages from locust later on') + ask_yes_no('are you sure you know what you are doing?') + elif args.command == 'local' and which('behave') is None: + parser.error_no_help('"behave" not found in PATH, needed when running local mode') + + if args.testdata_variable is not None: + for variable in args.testdata_variable: + try: + [name, value] = variable.split('=', 1) + os.environ[f'TESTDATA_VARIABLE_{name}'] = value + except ValueError: # noqa: PERF203 + parser.error_no_help('-T/--testdata-variable needs to be in the format NAME=VALUE') + + if args.csv_prefix is None: + if args.csv_interval is not None: + parser.error_no_help('--csv-interval can only be used in combination with --csv-prefix') + + if args.csv_flush_interval is not None: + parser.error_no_help('--csv-flush-interval can only be used in combination with --csv-prefix') + + def _parse_arguments() -> argparse.Namespace: parser = _create_parser() args = parser.parse_args() if hasattr(args, 'file'): # needed to support file names with spaces, which is escaped (sh-style) - setattr(args, 'file', ' '.join(args.file)) + args.file = ' '.join(args.file) if args.version: - if __version__ == '0.0.0': - version = '(development)' - else: - version = __version__ - - grizzly_versions: Optional[Tuple[Optional[str], Optional[List[str]]]] = None - - if args.version == 'all': - grizzly_versions, locust_version = get_dependency_versions(False) - else: - grizzly_versions, locust_version = None, None - - print(f'grizzly-cli {version}') - if grizzly_versions is not None: - grizzly_version, grizzly_extras = grizzly_versions - if grizzly_version is not None: - print(f'└── grizzly {grizzly_version}', end='') - if grizzly_extras is not None and len(grizzly_extras) > 0: - print(f' ── extras: {", ".join(grizzly_extras)}', end='') - print('') - - if locust_version is not None: - print(f' └── locust {locust_version}') - - raise SystemExit(0) + _parse_show_version(args) if args.command is None: parser.error('no command specified') @@ -101,36 +130,15 @@ def _parse_arguments() -> argparse.Namespace: parser.error_no_help('cannot run distributed') if args.registry is not None and not args.registry.endswith('/'): - setattr(args, 'registry', f'{args.registry}/') + args.registry = f'{args.registry}/' elif args.command in ['init', 'auth']: - setattr(args, 'subcommand', None) + args.subcommand = None if args.subcommand == 'run': - if args.command == 'dist': - if args.limit_nofile < 10001 and not args.yes: - print('!! this will cause warning messages from locust later on') - ask_yes_no('are you sure you know what you are doing?') - elif args.command == 'local': - if which('behave') is None: - parser.error_no_help('"behave" not found in PATH, needed when running local mode') - - if args.testdata_variable is not None: - for variable in args.testdata_variable: - try: - [name, value] = variable.split('=', 1) - os.environ[f'TESTDATA_VARIABLE_{name}'] = value - except ValueError: - parser.error_no_help('-T/--testdata-variable needs to be in the format NAME=VALUE') - - if args.csv_prefix is None: - if args.csv_interval is not None: - parser.error_no_help('--csv-interval can only be used in combination with --csv-prefix') - - if args.csv_flush_interval is not None: - parser.error_no_help('--csv-flush-interval can only be used in combination with --csv-prefix') + _parse_run(parser, args) elif args.command == 'dist' and args.subcommand == 'build': - setattr(args, 'force_build', args.no_cache) - setattr(args, 'build', not args.no_cache) + args.force_build = args.no_cache + args.build = not args.no_cache log_file = getattr(args, 'log_file', None) setup_logging(log_file) @@ -139,8 +147,8 @@ def _parse_arguments() -> argparse.Namespace: def _inject_additional_arguments_from_metadata(args: argparse.Namespace) -> argparse.Namespace: - with open(args.file) as fd: - file_metadata = [line.strip().replace('# grizzly-cli ', '').split(' ') for line in fd.readlines() if line.strip().startswith('# grizzly-cli ')] + with Path(args.file).open() as fd: + file_metadata = [line.strip().replace('# grizzly-cli ', '').split(' ') for line in fd if line.strip().startswith('# grizzly-cli ')] if len(file_metadata) < 1: return args @@ -149,12 +157,12 @@ def _inject_additional_arguments_from_metadata(args: argparse.Namespace) -> argp for additional_arguments in file_metadata: try: if additional_arguments[0].strip().startswith('-'): - raise ValueError() + raise ValueError index = argv.index(additional_arguments[0]) + 1 for zindex, additional_argument in enumerate(additional_arguments[1:]): argv.insert(index + zindex, additional_argument) - except ValueError: + except ValueError: # noqa: PERF203 print('?? ignoring {}'.format(' '.join(additional_arguments))) sys.argv = sys.argv[0:1] + argv @@ -182,18 +190,16 @@ def main() -> int: elif args.command == 'keyvault': rc = keyvault(args) else: - raise ValueError(f'unknown command {args.command}') - - return rc + message = f'unknown command {args.command}' + raise ValueError(message) except (KeyboardInterrupt, ValueError) as e: - print('') + print() if isinstance(e, ValueError): - if args is not None and getattr(args, 'verbose', False): - exception = format_exc() - else: - exception = str(e) + exception = format_exc() if args is not None and getattr(args, 'verbose', False) else str(e) print(exception) print('\n!! aborted grizzly-cli') return 1 + else: + return rc diff --git a/grizzly_cli/argparse/__init__.py b/grizzly_cli/argparse/__init__.py index 11e238a..858ec2c 100644 --- a/grizzly_cli/argparse/__init__.py +++ b/grizzly_cli/argparse/__init__.py @@ -1,18 +1,26 @@ -import sys +from __future__ import annotations + import re +import sys +from argparse import ArgumentParser as CoreArgumentParser +from argparse import Namespace, _SubParsersAction +from typing import TYPE_CHECKING, Any, Optional, cast -from typing import Any, Optional, IO, Sequence, cast -from argparse import ArgumentParser as CoreArgumentParser, Namespace, _SubParsersAction +from grizzly_cli.argparse.bashcompletion import BashCompletionAction +from grizzly_cli.argparse.bashcompletion import hook as bashcompletion_hook +from grizzly_cli.argparse.markdown import MarkdownFormatter, MarkdownHelpAction -from .markdown import MarkdownFormatter, MarkdownHelpAction -from .bashcompletion import BashCompletionAction, hook as bashcompletion_hook +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Sequence + from typing import IO + from _typeshed import SupportsWrite ArgumentSubParser = _SubParsersAction class ArgumentParser(CoreArgumentParser): - def __init__(self, markdown_help: bool = False, bash_completion: bool = False, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: Any, markdown_help: bool = False, bash_completion: bool = False, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.markdown_help = markdown_help @@ -27,17 +35,17 @@ def __init__(self, markdown_help: bool = False, bash_completion: bool = False, * self._optionals.title = 'optional arguments' def error_no_help(self, message: str) -> None: - sys.stderr.write('{}: error: {}\n'.format(self.prog, message)) + sys.stderr.write(f'{self.prog}: error: {message}\n') sys.exit(2) - def print_help(self, file: Optional[IO[str]] = None) -> None: - '''Hook to make help more command line friendly, if there is markdown markers in the text. - ''' + def print_help(self, file: Optional[SupportsWrite[str]] = None) -> None: + """Make help more command line friendly, if there is markdown markers in the text.""" + file = cast('Optional[IO[str]]', file) if not self.markdown_help: super().print_help(file) return - if cast(type, self.formatter_class) is not MarkdownFormatter: + if self.formatter_class is not MarkdownFormatter: original_description = self.description original_actions = self._actions @@ -53,14 +61,12 @@ def print_help(self, file: Optional[IO[str]] = None) -> None: super().print_help(file) - if cast(type, self.formatter_class) is not MarkdownFormatter: + if self.formatter_class is not MarkdownFormatter: self.description = original_description self._actions = original_actions - def parse_args(self, args: Optional[Sequence[str]] = None, namespace: Optional[Namespace] = None) -> Namespace: # type: ignore - """ - Hook to add `--bash-complete` to all parsers, if enabled for parser. - """ + def parse_args(self, args: Optional[Sequence[str]] = None, namespace: Optional[Namespace] = None) -> Namespace: # type: ignore[override] + """Add `--bash-complete` to all parsers, if enabled for parser.""" if self.bash_completion: bashcompletion_hook(self) diff --git a/grizzly_cli/argparse/bashcompletion/__init__.py b/grizzly_cli/argparse/bashcompletion/__init__.py index 9a1f786..c816203 100644 --- a/grizzly_cli/argparse/bashcompletion/__init__.py +++ b/grizzly_cli/argparse/bashcompletion/__init__.py @@ -1,25 +1,28 @@ -import sys +from __future__ import annotations -from typing import Any, Dict, List, Union, Sequence, Optional, cast +import sys from argparse import ( - ArgumentParser, - ArgumentError, + SUPPRESS, Action, + ArgumentError, + ArgumentParser, Namespace, - _SubParsersAction, - _StoreConstAction, _AppendAction, _StoreAction, - SUPPRESS, + _StoreConstAction, + _SubParsersAction, ) +from collections.abc import Sequence from os import path +from pathlib import Path +from typing import Any, Optional, Union, cast -from .types import BashCompletionTypes +from grizzly_cli.argparse.bashcompletion.types import BashCompletionTypes __all__ = [ - 'BashCompletionTypes', - 'BashCompletionAction', 'BashCompleteAction', + 'BashCompletionAction', + 'BashCompletionTypes', 'hook', ] @@ -27,17 +30,17 @@ class BashCompletionAction(Action): def __init__( self, - option_strings: List[str], + option_strings: list[str], dest: str = SUPPRESS, default: str = SUPPRESS, - help: str = SUPPRESS, + help_text: str = SUPPRESS, **kwargs: Any, ) -> None: super().__init__( option_strings=option_strings, dest=dest, default=default, - help=help, + help=help_text, nargs=0, **kwargs, ) @@ -45,12 +48,13 @@ def __init__( def __call__( self, parser: ArgumentParser, - namespace: Namespace, - values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None, + *_args: Any, + **_kwargs: Any, ) -> None: - file_directory = path.dirname(__file__) - with open(path.join(file_directory, 'bashcompletion.bash'), encoding='utf-8') as fd: + current_file = Path(__file__) + file_directory = current_file.parent + bash_script = file_directory / 'bashcompletion.bash' + with bash_script.open(encoding='utf-8') as fd: print(fd.read().replace('bashcompletion_template', parser.prog)) parser.exit() @@ -59,56 +63,51 @@ def __call__( class BashCompleteAction(Action): def __init__( self, - option_strings: List[str], + option_strings: list[str], dest: str = SUPPRESS, default: str = SUPPRESS, - help: str = SUPPRESS, + help_text: str = SUPPRESS, **kwargs: Any, ) -> None: super().__init__( option_strings=option_strings, dest=dest, default=default, - help=help, + help=help_text, nargs=None, **kwargs, ) - def get_suggestions(self, parser: ArgumentParser) -> Dict[str, Union[str, Action]]: - suggestions: Dict[str, Union[str, Action]] = {} + def get_suggestions(self, parser: ArgumentParser) -> dict[str, Union[str, Action]]: + suggestions: dict[str, Union[str, Action]] = {} for action in parser._actions: - if isinstance(action, (BashCompleteAction, BashCompletionAction, )) or (SUPPRESS in [action.help, action.default] and action.dest != 'help'): + if isinstance(action, (BashCompleteAction, BashCompletionAction)) or (SUPPRESS in [action.help, action.default] and action.dest != 'help'): continue - elif isinstance(action, _SubParsersAction): - suggestions.update({key: action for key in action.choices.keys()}) + + if isinstance(action, _SubParsersAction): + suggestions.update(dict.fromkeys(action.choices.keys(), action)) + elif len(action.option_strings) > 0: + suggestions.update(dict.fromkeys(action.option_strings, action)) else: - if len(action.option_strings) > 0: - suggestions.update({key: action for key in action.option_strings}) - else: - suggestions.update({action.dest: action}) + suggestions.update({action.dest: action}) return suggestions - def get_exclusive_suggestions(self, parser: ArgumentParser) -> Dict[str, List[str]]: - exclusive_suggestions: Dict[str, List[str]] = {} + def get_exclusive_suggestions(self, parser: ArgumentParser) -> dict[str, list[str]]: + exclusive_suggestions: dict[str, list[str]] = {} for group in parser._mutually_exclusive_groups: - exclusives: List[str] = [] + exclusives: list[str] = [] for action in group._group_actions: - for option in action.option_strings: - exclusives.append(option) + exclusives.extend(action.option_strings) for exclusive in exclusives: - exclusives_to: List[str] = [] - for exclusive_to in filter(lambda x: x != exclusive, exclusives): - exclusives_to.append(exclusive_to) - - exclusive_suggestions.update({exclusive: exclusives_to}) + exclusive_suggestions.update({exclusive: list(filter(lambda x: x != exclusive, exclusives))}) return exclusive_suggestions - def get_provided_options(self, prog: str, values: Union[str, Sequence[Any], None]) -> List[str]: - options: List[str] = [] + def get_provided_options(self, prog: str, values: Union[str, Sequence[Any], None]) -> list[str]: + options: list[str] = [] if isinstance(values, str): options = [value for value in values.replace(f'{prog}', '').split(' ') if len(value.strip()) > 0] @@ -117,11 +116,11 @@ def get_provided_options(self, prog: str, values: Union[str, Sequence[Any], None return options - def remove_completed(self, provided_options: List[str], suggestions: Dict[str, Union[str, Action]], exclusive_suggestions: Dict[str, List[str]]) -> List[str]: + def remove_completed(self, provided_options: list[str], suggestions: dict[str, Union[str, Action]], exclusive_suggestions: dict[str, list[str]]) -> list[str]: # noqa: C901, PLR0912 if len(provided_options) <= 1: return provided_options - filtered_options: List[str] = [] + filtered_options: list[str] = [] skip: bool = False concat: bool = False @@ -131,13 +130,13 @@ def remove_completed(self, provided_options: List[str], suggestions: Dict[str, U if concat: concat = False - option = '{} {}'.format(provided_options[index - 1], option) + option = f'{provided_options[index - 1]} {option}' # noqa: PLW2901 if len(option) < 1 or skip: skip = False continue - suggestion = suggestions.get(option, None) + suggestion = suggestions.get(option) if suggestion is not None: if isinstance(suggestion, _AppendAction): @@ -159,29 +158,26 @@ def remove_completed(self, provided_options: List[str], suggestions: Dict[str, U if remove_suggestion and isinstance(suggestion, Action): # remove all other, completed, options from suggestion for suggestion_option in suggestion.option_strings: - if suggestion_option in suggestions: - del suggestions[suggestion_option] + suggestions.pop(suggestion_option, None) # remove options that are mutually exclusive to completed option exclusive_removes = exclusive_suggestions.get(suggestion_option, []) for exclusive_option in exclusive_removes: - if exclusive_option in suggestions: - del suggestions[exclusive_option] + suggestions.pop(exclusive_option, None) continue - elif not any([suggested_option.startswith(option) for suggested_option in suggestions.keys()]): # could be values for an option + elif not any(suggested_option.startswith(option) for suggested_option in suggestions): # could be values for an option remove = True if option.endswith('\\') and sys.platform != 'win32': concat = True continue for suggestion in suggestions.values(): - if isinstance(suggestion, Action) and len(suggestion.option_strings) == 0: - if isinstance(suggestion.type, BashCompletionTypes.File): - file_suggestions = cast(BashCompletionTypes.File, suggestion.type).list_files(option) # type: ignore - for file in file_suggestions.keys(): - if file.startswith(option): - remove = False - break + if isinstance(suggestion, Action) and len(suggestion.option_strings) == 0 and isinstance(suggestion.type, BashCompletionTypes.File): + file_suggestions = suggestion.type.list_files(option) + for file in file_suggestions: + if file.startswith(option): + remove = False + break if remove: continue @@ -193,11 +189,11 @@ def remove_completed(self, provided_options: List[str], suggestions: Dict[str, U return filtered_options - def filter_suggestions(self, provided_options: List[str], suggestions: Dict[str, Union[str, Action]]) -> Dict[str, Union[str, Action]]: + def filter_suggestions(self, provided_options: list[str], suggestions: dict[str, Union[str, Action]]) -> dict[str, Union[str, Action]]: if len(provided_options) < 1: return suggestions - filtered_suggestions: Dict[str, Union[str, Action]] = {} + filtered_suggestions: dict[str, Union[str, Action]] = {} for option in provided_options: for option_suggestion, suggestion in suggestions.items(): if option_suggestion.startswith(option) or ( @@ -210,19 +206,19 @@ def filter_suggestions(self, provided_options: List[str], suggestions: Dict[str, return filtered_suggestions - def __call__( + def __call__( # noqa: C901, PLR0912, PLR0915 self, parser: ArgumentParser, - namespace: Namespace, + namespace: Namespace, # noqa: ARG002 values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None, + option_string: Optional[str] = None, # noqa: ARG002 ) -> None: - all_suggestions: Dict[str, Union[str, Action]] = {} + all_suggestions: dict[str, Union[str, Action]] = {} provided_options = self.get_provided_options(parser.prog, values) suggestions = self.get_suggestions(parser) if '-h' in provided_options or '--help' in provided_options: - print('') + print() parser.exit() # map options that are only allowed by it own @@ -248,32 +244,25 @@ def __call__( suggestions = all_suggestions for option in suggestion.option_strings: del suggestions[option] - elif isinstance(suggestion, (_AppendAction, _StoreAction,)): + elif isinstance(suggestion, (_AppendAction, _StoreAction)): # value for append action has been provided - if len(provided_options) == 2: - suggestions = all_suggestions - else: - # no value, supplied, do not suggest anything - suggestions = {} + suggestions = all_suggestions if len(provided_options) == 2 else {} # based on option value type suggestion = all_suggestions.get(provided_options[0], None) if isinstance(suggestion, Action) and suggestion.type is not None: value = provided_options[-1] if len(provided_options) > 1 else None if isinstance(suggestion.type, BashCompletionTypes.File): - file_suggestions = cast(BashCompletionTypes.File, suggestion.type).list_files(value) # type: ignore + file_suggestions = suggestion.type.list_files(value) if not (len(file_suggestions) == 1 and provided_options[-1] in file_suggestions): - suggestions = cast(Dict[str, Union[str, Action]], file_suggestions) + suggestions = cast('dict[str, Union[str, Action]]', file_suggestions) else: suggestions = all_suggestions for option in suggestion.option_strings: del suggestions[option] - else: - if suggestion.type == str and not isinstance(value, str): - suggestions = {} - elif suggestion.type == int and (value is None or not value.isnumeric()): - suggestions = {} + elif (suggestion.type is str and not isinstance(value, str)) or (suggestion.type is int and (value is None or not value.isnumeric())): + suggestions = {} if value is not None and isinstance(suggestion, _StoreAction): for option in suggestion.option_strings: @@ -283,31 +272,30 @@ def __call__( # check for positionals original_suggestions = suggestions.copy() for option, suggestion in original_suggestions.items(): - if (option.startswith('-') and isinstance(suggestion, Action)) or (not option.startswith('-') and isinstance(suggestion, (_SubParsersAction, str,))): + if (option.startswith('-') and isinstance(suggestion, Action)) or (not option.startswith('-') and isinstance(suggestion, (_SubParsersAction, str))): continue del suggestions[option] - if isinstance(suggestion, Action) and suggestion.type is not None: - if isinstance(cast(Any, suggestion.type), BashCompletionTypes.File): - value = provided_options[-1] if len(provided_options) == 1 and not provided_options[-1].startswith('-') else None - - if (value is None and len(provided_options) == 0) or (value is not None and len(provided_options) == 1): - file_suggestions = cast(BashCompletionTypes.File, suggestion.type).list_files(value) - value_type = file_suggestions.get(value, None) if value is not None else None - - # check if suggestion matching provided option (value) is a directory, and if - # provded option (value) does not end with a path separator, it should be added - # otherwise it will not be completed correctly - if value_type == 'dir' and (value is not None and not value.endswith(path.sep)): - value = '{value}{sep}'.format(value=value, sep=path.sep) - - # only provide further suggestions if matches isn't a completed file path - if not (len(file_suggestions) == 1 and value in file_suggestions) and (value_type is None or value_type != 'file'): - suggestions.update(file_suggestions) - else: - suggestions = all_suggestions - del suggestions[suggestion.dest] + if isinstance(suggestion, Action) and suggestion.type is not None and isinstance(suggestion.type, BashCompletionTypes.File): + value = provided_options[-1] if len(provided_options) == 1 and not provided_options[-1].startswith('-') else None + + if (value is None and len(provided_options) == 0) or (value is not None and len(provided_options) == 1): + file_suggestions = suggestion.type.list_files(value) + value_type = file_suggestions.get(value, None) if value is not None else None + + # check if suggestion matching provided option (value) is a directory, and if + # provded option (value) does not end with a path separator, it should be added + # otherwise it will not be completed correctly + if value_type == 'dir' and (value is not None and not value.endswith(path.sep)): + value = f'{value}{path.sep}' + + # only provide further suggestions if matches isn't a completed file path + if not (len(file_suggestions) == 1 and value in file_suggestions) and (value_type is None or value_type != 'file'): + suggestions.update(file_suggestions) + else: + suggestions = all_suggestions + del suggestions[suggestion.dest] print('\n'.join(suggestions.keys())) parser.exit() @@ -319,7 +307,7 @@ def hook(parser: ArgumentParser) -> None: except ArgumentError as e: # we've already "hooked" the parser if 'conflicting option string: --bash-complete' not in e.message: - raise e + raise except Exception: raise finally: diff --git a/grizzly_cli/argparse/bashcompletion/types.py b/grizzly_cli/argparse/bashcompletion/types.py index ffe4259..4a06e21 100644 --- a/grizzly_cli/argparse/bashcompletion/types.py +++ b/grizzly_cli/argparse/bashcompletion/types.py @@ -1,18 +1,17 @@ -import sys +from __future__ import annotations -from typing import Dict, Optional -from glob import glob -from os import getcwd -from os.path import sep as path_separator, exists, isfile -from fnmatch import filter as fnmatch_filter +import sys from argparse import ArgumentTypeError - +from fnmatch import filter as fnmatch_filter +from os.path import sep as path_sep +from pathlib import Path +from typing import Optional, cast __all__ = [ 'BashCompletionTypes', ] -ESCAPE_CHARACTERS = { +ESCAPE_CHARACTERS: dict[str, str | int | None] = { ' ': '\\ ', '(': '\\(', ')': '\\)', @@ -21,57 +20,84 @@ class BashCompletionTypes: class File: - _cwd: str = getcwd() - def __init__(self, *args: str, missing_ok: bool = False) -> None: self.patterns = list(args) - self.cwd = BashCompletionTypes.File._cwd + self.cwd = Path.cwd() self.missing_ok = missing_ok def __call__(self, value: str) -> str: if self.missing_ok: return value - if not exists(value): - raise ArgumentTypeError(f'{value} does not exist') + file = Path(value) - if not isfile(value): - raise ArgumentTypeError(f'{value} is not a file') + if not file.exists(): + message = f'{value} does not exist' + raise ArgumentTypeError(message) + + if not file.is_file(): + message = f'{value} is not a file' + raise ArgumentTypeError(message) matches = [match for pattern in self.patterns for match in fnmatch_filter([value], pattern)] if len(matches) < 1: - raise ArgumentTypeError(f'{value} does not match {", ".join(self.patterns)}') + message = f'{value} does not match {", ".join(self.patterns)}' + raise ArgumentTypeError(message) + + return value + + @classmethod + def _transform_path(cls, value: str) -> str: + value = value.translate(str.maketrans(ESCAPE_CHARACTERS)) + if sys.platform == 'win32': + value = value.replace('/', path_sep) return value - def list_files(self, value: Optional[str]) -> Dict[str, str]: - matches: Dict[str, str] = {} + def list_files(self, value: Optional[str]) -> dict[str, str]: + matches: dict[str, str] = {} if value is not None: - if value.endswith('\\') and sys.platform != 'win32': - value += ' ' - value = value.replace('\\ ', ' ').replace('\\(', '(').replace('\\)', ')') + if sys.platform == 'win32': + value = value.replace(path_sep, '/') # posix style + + for chr_with, chr_replace in ESCAPE_CHARACTERS.items(): + value = value.replace(cast('str', chr_replace), chr_with) for pattern in self.patterns: - for path in glob('**/{pattern}'.format(pattern=pattern), recursive=True): - path_match = path.replace('{cwd}{path_separator}'.format(cwd=self.cwd, path_separator=path_separator), '') + for path in self.cwd.rglob(f'**/{pattern}'): + try: + path_match = path.relative_to(self.cwd) + except ValueError: + path_match = path + + path_match_value = path_match.as_posix() - if path_match.startswith('.') or (value is not None and not path_match.startswith(value)): + # skip any paths where any part is hidden, or any path that is not (partially) relative to provided value + if any(part.startswith('.') for part in path_match.parts) or (value is not None and not path_match_value.startswith(value)): continue - match: Optional[Dict[str, str]] = None + match: Optional[dict[str, str]] = None - if path_separator in path_match: + # all paths are treated in posix style + if '/' in path_match_value: # there is a directory in the match try: + """ + find first part that matches with provided value; + value = `hel` + path_match_value = `hello/example.txt` + should be `hello`, and a dir(ectory) + """ index_match = len(value or '') - index_sep = path_match[index_match:].index(path_separator) + index_match - match = {path_match[:index_sep].translate(str.maketrans(ESCAPE_CHARACTERS)): 'dir'} # type: ignore + index_sep = path_match_value.index('/', index_match) + match = {self._transform_path(path_match_value[:index_sep]): 'dir'} except ValueError: + # no match against provided value, so assume file pass if match is None: - match = {path_match.translate(str.maketrans(ESCAPE_CHARACTERS)): 'file'} # type: ignore + match = {self._transform_path(path_match_value): 'file'} matches.update(match) diff --git a/grizzly_cli/argparse/markdown.py b/grizzly_cli/argparse/markdown.py index 4273555..5fd8115 100644 --- a/grizzly_cli/argparse/markdown.py +++ b/grizzly_cli/argparse/markdown.py @@ -1,26 +1,29 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List, Union, Sequence, Optional, Iterable, Tuple, Callable, Type, cast -from types import MethodType -from argparse import Action, SUPPRESS, ArgumentParser, Namespace, HelpFormatter + +from argparse import SUPPRESS, Action, ArgumentParser, HelpFormatter, Namespace from textwrap import fill as textwrap_fill +from types import MethodType +from typing import TYPE_CHECKING, Any, Callable, Optional, Union, cast if TYPE_CHECKING: # pragma: no cover + from collections.abc import Iterable, Sequence + from typing_extensions import Self __all__ = [ - 'MarkdownHelpAction', 'MarkdownFormatter', + 'MarkdownHelpAction', ] class MarkdownHelpAction(Action): def __init__( self, - option_strings: List[str], + option_strings: list[str], dest: str = SUPPRESS, default: str = SUPPRESS, - help: str = SUPPRESS, + help: str = SUPPRESS, # noqa: A002 **kwargs: Any, ) -> None: super().__init__( @@ -35,9 +38,9 @@ def __init__( def __call__( self, parser: ArgumentParser, - namespace: Namespace, - values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None, + namespace: Namespace, # noqa: ARG002 + values: Union[str, Sequence[Any], None], # noqa: ARG002 + option_string: Optional[str] = None, # noqa: ARG002 ) -> None: self.print_help(parser) @@ -53,10 +56,7 @@ def format_help_markdown(self: ArgumentParser) -> str: # usage formatter.add_text('\n') - formatter.add_usage(self.usage, self._actions, - self._mutually_exclusive_groups) - - # XXX: formatter.add_text(self.description) -- used to be here + formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups) # positionals, optionals and user-defined groups for action_group in self._action_groups: @@ -73,9 +73,9 @@ def format_help_markdown(self: ArgumentParser) -> str: # parser.print_help() @@ -98,22 +98,22 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._current_section = self._root_section @staticmethod - def factory(level: int) -> Type['MarkdownFormatter']: + def factory(level: int) -> type[MarkdownFormatter]: return type('MarkdownFormatterInstance', (MarkdownFormatter,), {'level': level}) class _MarkdownSection(HelpFormatter._Section): - def __init__(self, formatter: 'MarkdownFormatter', parent: Optional[Self], heading: Optional[str] = None) -> None: + def __init__(self, formatter: MarkdownFormatter, parent: Optional[Self], heading: Optional[str] = None) -> None: self.formatter = formatter self.parent = parent self.heading = heading - self.items: List[Tuple[Callable[..., str], Iterable[Any]]] = [] + self.items: list[tuple[Callable[..., str], Iterable[Any]]] = [] def format_help(self) -> str: # format the indented section if self.parent is not None: self.formatter._indent() join = self.formatter._join_parts - helps: List[str] = [] + helps: list[str] = [] # only one table header per section print_table_headers = True @@ -122,16 +122,15 @@ def format_help(self) -> str: name = getattr(func, '__name__', repr(func)) # we need to fix headers for argument tables - if name == '_format_action': - if print_table_headers and len(item_help_text) > 0: - helps.extend([ - '\n', - '| argument | default | help |', - '\n', - '| -------- | ------- | ---- |', - '\n', - ]) - print_table_headers = False + if name == '_format_action' and print_table_headers and len(item_help_text) > 0: + helps.extend([ + '\n', + '| argument | default | help |', + '\n', + '| -------- | ------- | ---- |', + '\n', + ]) + print_table_headers = False helps.append(item_help_text) @@ -147,14 +146,14 @@ def format_help(self) -> str: # add the heading if the section was non-empty if self.heading is not SUPPRESS and self.heading is not None: current_indent = self.formatter._current_indent - heading = '%*s%s\n' % (current_indent, '', self.heading) + heading = '%*s%s\n' % (current_indent, '', self.heading) # noqa: UP031 # increase header if we're in a subparser assert isinstance(self.formatter, MarkdownFormatter) if self.formatter.level > 0: # a bit hackish, to get a line break when adding a subparsers help if self.parent is None: - print('') + print() heading = f'#{heading}' else: @@ -191,13 +190,13 @@ def format_help(self) -> str: def _format_text(self, text: str) -> str: if '%(prog)' in text: - text = text % dict(prog=self._prog) + text = text % {'prog': self._prog} if len(text.strip()) > 0: - lines: List[str] = [] + lines: list[str] = [] for line in text.split('\n'): - line = textwrap_fill(line, 120) - lines.append(line) + filled_line = textwrap_fill(line, 120) + lines.append(filled_line) text = '\n'.join(lines) return text @@ -208,7 +207,7 @@ def start_section(self, heading: Optional[str]) -> None: heading = f'{"#" * self.current_level}# {heading}' self._indent() - section = self._MarkdownSection(self, cast(MarkdownFormatter._MarkdownSection, self._current_section), heading) + section = self._MarkdownSection(self, cast('MarkdownFormatter._MarkdownSection', self._current_section), heading) self._add_item(section.format_help, []) self._current_section = section @@ -218,7 +217,7 @@ def _format_action(self, action: Action) -> str: if 'help' in action.dest or action.dest == SUPPRESS: return '' - lines: List[str] = [] + lines: list[str] = [] if action.help is not None: expanded_help = self._expand_help(action) @@ -228,9 +227,9 @@ def _format_action(self, action: Action) -> str: argument = ', '.join(action.option_strings) if getattr(action, 'option_strings', None) is not None and len(action.option_strings) > 0 else action.dest default = f'`{action.default}`' if action.default is not None else '' - help = '
'.join(help_text) + help_value = '
'.join(help_text) # format arguments as a markdown table row - lines.extend([f'| `{argument}` | {default} | {help} |', '']) + lines.extend([f'| `{argument}` | {default} | {help_value} |', '']) return '\n'.join(lines) diff --git a/grizzly_cli/auth.py b/grizzly_cli/auth.py index 1e47e07..66cbb01 100644 --- a/grizzly_cli/auth.py +++ b/grizzly_cli/auth.py @@ -1,14 +1,18 @@ -import sys +from __future__ import annotations +import sys from os import environ -from argparse import Namespace as Arguments -from typing import Optional from pathlib import Path +from typing import TYPE_CHECKING, Optional from pyotp import TOTP -from . import register_parser -from .argparse import ArgumentSubParser +from grizzly_cli import register_parser + +if TYPE_CHECKING: # pragma: no cover + from argparse import Namespace as Arguments + + from grizzly_cli.argparse import ArgumentSubParser @register_parser() @@ -27,7 +31,7 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: help=( 'where to read OTP secret, nothing specified means environment variable OTP_SECRET, ' '`-` means stdin and anything else is considered a file' - ) + ), ) if auth_parser.prog != 'grizzly-cli auth': # pragma: no cover @@ -40,31 +44,36 @@ def auth(args: Arguments) -> int: if args.input is None: secret = environ.get('OTP_SECRET', None) if secret is None: - raise ValueError('environment variable OTP_SECRET is not set') + message = 'environment variable OTP_SECRET is not set' + raise ValueError(message) elif args.input == '-': try: secret = sys.stdin.read().strip() - except: + except: # noqa: S110 pass finally: if secret is None or len(secret.strip()) < 1: - raise ValueError('OTP secret could not be read from stdin') + message = 'OTP secret could not be read from stdin' + raise ValueError(message) else: input_file = Path(args.input) if not input_file.exists(): - raise ValueError(f'file {input_file} does not exist') + message = f'file {input_file.as_posix()} does not exist' + raise ValueError(message) secret = input_file.read_text().strip() if ' ' in secret or len(secret.split('\n')) > 1 or secret == '': - raise ValueError(f'file {input_file} does not seem to contain a single line with a valid OTP secret') + message = f'file {input_file.as_posix()} does not seem to contain a single line with a valid OTP secret' + raise ValueError(message) try: totp = TOTP(secret) print(totp.now()) except Exception as e: - raise ValueError(f'unable to generate TOTP code: {e}') + message = f'unable to generate TOTP code: {e!s}' + raise ValueError(message) from e return 0 diff --git a/grizzly_cli/distributed/__init__.py b/grizzly_cli/distributed/__init__.py index 8824f77..be4227e 100644 --- a/grizzly_cli/distributed/__init__.py +++ b/grizzly_cli/distributed/__init__.py @@ -1,28 +1,34 @@ -import os -import sys +from __future__ import annotations + import argparse +import os import subprocess - -from typing import List, Dict, Any, cast -from tempfile import NamedTemporaryFile -from getpass import getuser -from shutil import get_terminal_size +import sys from argparse import Namespace as Arguments -from socket import gethostname -from json import loads as jsonloads +from getpass import getuser from io import StringIO +from json import loads as jsonloads from pathlib import Path - -from grizzly_cli import EXECUTION_CONTEXT, STATIC_CONTEXT, MOUNT_CONTEXT, PROJECT_NAME, register_parser +from shutil import get_terminal_size +from socket import gethostname +from tempfile import NamedTemporaryFile +from typing import IO, TYPE_CHECKING + +from grizzly_cli import EXECUTION_CONTEXT, MOUNT_CONTEXT, PROJECT_NAME, STATIC_CONTEXT, register_parser +from grizzly_cli.distributed.build import build as do_build +from grizzly_cli.distributed.build import create_parser as build_create_parser +from grizzly_cli.distributed.clean import clean as do_clean +from grizzly_cli.distributed.clean import create_parser as clean_create_parser +from grizzly_cli.run import create_parser as run_create_parser +from grizzly_cli.run import run from grizzly_cli.utils import ( - run_command, get_default_mtu, list_images, + run_command, ) -from grizzly_cli.run import create_parser as run_create_parser, run -from grizzly_cli.argparse import ArgumentSubParser -from .build import build as do_build, create_parser as build_create_parser -from .clean import clean as do_clean, create_parser as clean_create_parser + +if TYPE_CHECKING: # pragma: no cover + from grizzly_cli.argparse import ArgumentSubParser @register_parser(order=3) @@ -101,7 +107,7 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: help=( 'sets enviroment variable LOCUST_WAIT_FOR_WORKERS_REPORT_AFTER_RAMP_UP, which tells master to wait ' 'this amount of time for worker report' - ) + ), ) dist_parser.add_argument( @@ -144,29 +150,17 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: def distributed(args: Arguments) -> int: if args.subcommand == 'run': return run(args, distributed_run) - elif args.subcommand == 'build': + + if args.subcommand == 'build': return do_build(args) - elif args.subcommand == 'clean': + if args.subcommand == 'clean': return do_clean(args) - else: - raise ValueError(f'unknown subcommand {args.subcommand}') + message = f'unknown subcommand {args.subcommand}' + raise ValueError(message) -def distributed_run(args: Arguments, environ: Dict[str, Any], run_arguments: Dict[str, List[str]]) -> int: - suffix = '' if args.id is None else f'-{args.id}' - tag = getuser() - - if args.project_name is None: - project_name = PROJECT_NAME - else: - project_name = args.project_name - - # default locust project - compose_args: List[str] = [ - '-p', f'{project_name}{suffix}-{tag}', - '-f', f'{STATIC_CONTEXT}/compose.yaml', - ] +def update_os_environ(args: Arguments, run_arguments: dict[str, list[str]], project_name: str, tag: str) -> None: if args.file is not None: os.environ['GRIZZLY_RUN_FILE'] = args.file @@ -182,26 +176,26 @@ def distributed_run(args: Arguments, environ: Dict[str, Any], run_arguments: Dic columns, lines = get_terminal_size() # set environment variables needed by compose files, when * compose executes - os.environ['GRIZZLY_MTU'] = cast(str, mtu) - os.environ['GRIZZLY_EXECUTION_CONTEXT'] = EXECUTION_CONTEXT - os.environ['GRIZZLY_STATIC_CONTEXT'] = STATIC_CONTEXT - os.environ['GRIZZLY_MOUNT_CONTEXT'] = MOUNT_CONTEXT - os.environ['GRIZZLY_PROJECT_NAME'] = project_name - os.environ['GRIZZLY_USER_TAG'] = tag - os.environ['GRIZZLY_EXPECTED_WORKERS'] = str(args.workers) - os.environ['GRIZZLY_LIMIT_NOFILE'] = str(args.limit_nofile) - os.environ['GRIZZLY_HEALTH_CHECK_RETRIES'] = str(args.health_retries) - os.environ['GRIZZLY_HEALTH_CHECK_INTERVAL'] = str(args.health_interval) - os.environ['GRIZZLY_HEALTH_CHECK_TIMEOUT'] = str(args.health_timeout) - os.environ['GRIZZLY_IMAGE_REGISTRY'] = getattr(args, 'registry', None) or '' - os.environ['GRIZZLY_CONTAINER_TTY'] = 'true' if args.tty else 'false' - os.environ['COLUMNS'] = str(columns) - os.environ['LINES'] = str(lines) + os.environ.update({ + 'GRIZZLY_MTU': str(mtu), + 'GRIZZLY_EXECUTION_CONTEXT': EXECUTION_CONTEXT, + 'GRIZZLY_STATIC_CONTEXT': STATIC_CONTEXT, + 'GRIZZLY_MOUNT_CONTEXT': MOUNT_CONTEXT, + 'GRIZZLY_PROJECT_NAME': project_name, + 'GRIZZLY_USER_TAG': tag, + 'GRIZZLY_EXPECTED_WORKERS': str(args.workers), + 'GRIZZLY_LIMIT_NOFILE': str(args.limit_nofile), + 'GRIZZLY_HEALTH_CHECK_RETRIES': str(args.health_retries), + 'GRIZZLY_HEALTH_CHECK_INTERVAL': str(args.health_interval), + 'GRIZZLY_HEALTH_CHECK_TIMEOUT': str(args.health_timeout), + 'GRIZZLY_IMAGE_REGISTRY': getattr(args, 'registry', None) or '', + 'GRIZZLY_CONTAINER_TTY': repr(args.tty).lower(), + 'COLUMNS': str(columns), + 'LINES': str(lines), + }) grizzly_mount_context_path = '' - name_template = '{project}{suffix}-{tag}-{node}-{index}' - if EXECUTION_CONTEXT != MOUNT_CONTEXT: hostname = gethostname() output = subprocess.check_output( @@ -227,53 +221,87 @@ def distributed_run(args: Arguments, environ: Dict[str, Any], run_arguments: Dic if len(run_arguments.get('common', [])) > 0: os.environ['GRIZZLY_COMMON_RUN_ARGS'] = ' '.join(run_arguments['common']) - # check if we need to build image - images = list_images(args) - with NamedTemporaryFile() as fd: - # file will be deleted when conContainertext exits - if len(environ) > 0: - for key, value in environ.items(): - if key == 'GRIZZLY_CONFIGURATION_FILE': - value = value.replace(EXECUTION_CONTEXT, MOUNT_CONTEXT).replace(MOUNT_CONTEXT, '/srv/grizzly') +def write_env_file(fd: IO[bytes], environ: dict, args: Arguments) -> None: + if len(environ) > 0: + for key, value in environ.items(): + transformed_value = value.replace(EXECUTION_CONTEXT, MOUNT_CONTEXT).replace(MOUNT_CONTEXT, '/srv/grizzly') if key == 'GRIZZLY_CONFIGURATION_FILE' else value - fd.write(f'{key}={value}\n'.encode('utf-8')) + fd.write(f'{key}={transformed_value}\n'.encode()) - fd.write(f'COLUMNS={columns}\n'.encode('utf-8')) - fd.write(f'LINES={lines}\n'.encode('utf-8')) - fd.write(f'GRIZZLY_CONTAINER_TTY={os.environ["GRIZZLY_CONTAINER_TTY"]}\n'.encode('utf-8')) + fd.write(f'COLUMNS={os.environ["COLUMNS"]}\n'.encode()) + fd.write(f'LINES={os.environ["LINES"]}\n'.encode()) + fd.write(f'GRIZZLY_CONTAINER_TTY={os.environ["GRIZZLY_CONTAINER_TTY"]}\n'.encode()) - if args.wait_for_worker is not None: - fd.write(f'LOCUST_WAIT_FOR_WORKERS_REPORT_AFTER_RAMP_UP="{args.wait_for_worker}"'.encode('utf-8')) + if args.wait_for_worker is not None: + fd.write(f'LOCUST_WAIT_FOR_WORKERS_REPORT_AFTER_RAMP_UP="{args.wait_for_worker}"'.encode()) - fd.flush() + fd.flush() - os.environ['GRIZZLY_ENVIRONMENT_FILE'] = fd.name + os.environ['GRIZZLY_ENVIRONMENT_FILE'] = fd.name - validate_config = getattr(args, 'validate_config', False) - compose_command = [ - args.container_system, 'compose', - *compose_args, - 'config', - ] +def should_validate_config(args: Arguments, compose_args: list[str]) -> int: + validate_config = getattr(args, 'validate_config', False) + + compose_command = [ + args.container_system, 'compose', + *compose_args, + 'config', + ] + + result = run_command(compose_command, silent=not validate_config) + + if validate_config or result.return_code != 0: + if result.return_code != 0 and not validate_config: + print('!! something in the compose project is not valid, check with:') + argv = sys.argv[:] + argv.insert(argv.index('dist') + 1, '--validate-config') + print(f'grizzly-cli {" ".join(argv[1:])}') + + return result.return_code + + return 0 - result = run_command(compose_command, silent=not validate_config) - if validate_config or result.return_code != 0: - if result.return_code != 0 and not validate_config: - print('!! something in the compose project is not valid, check with:') - argv = sys.argv[:] - argv.insert(argv.index('dist') + 1, '--validate-config') - print(f'grizzly-cli {" ".join(argv[1:])}') +def should_build_image(args: Arguments, project_name: str, tag: str) -> int: + images = list_images(args) + + if images.get(project_name, {}).get(tag, None) is None or args.force_build or args.build: + rc = do_build(args) + if rc != 0: + print(f'!! failed to build {project_name}, rc={rc}') + return rc + + return 0 + + +def distributed_run(args: Arguments, environ: dict, run_arguments: dict[str, list[str]]) -> int: + suffix = '' if args.id is None else f'-{args.id}' + tag = getuser() + + project_name = PROJECT_NAME if args.project_name is None else args.project_name + + # default locust project + compose_args: list[str] = [ + '-p', f'{project_name}{suffix}-{tag}', + '-f', f'{STATIC_CONTEXT}/compose.yaml', + ] + + update_os_environ(args, run_arguments, project_name, tag) + + name_template = '{project}{suffix}-{tag}-{node}-{index}' + + with NamedTemporaryFile() as fd: # file will be deleted when container exists + write_env_file(fd, environ, args) - return result.return_code + rc = should_validate_config(args, compose_args) + if rc != 0: + return rc - if images.get(project_name, {}).get(tag, None) is None or args.force_build or args.build: - rc = do_build(args) - if rc != 0: - print(f'!! failed to build {project_name}, rc={rc}') - return rc + rc = should_build_image(args, project_name, tag) + if rc != 0: + return rc compose_scale_argument = ['--scale', f'worker={args.workers}'] @@ -339,14 +367,14 @@ def distributed_run(args: Arguments, environ: Dict[str, Any], run_arguments: Dic stderr=subprocess.STDOUT, ).split('\n') - log_file = Path(args.log_file).open('a+') if args.log_file is not None else StringIO() + log_file = Path(args.log_file).open('a+') if args.log_file is not None else StringIO() # noqa: SIM115 try: for line in missed_output: formatted_line = f'{master_node_name} | {line}' print(formatted_line) log_file.write(f'{formatted_line}\n') - except: + except: # noqa: S110 pass finally: log_file.close() diff --git a/grizzly_cli/distributed/build.py b/grizzly_cli/distributed/build.py index feac3a0..8c27215 100644 --- a/grizzly_cli/distributed/build.py +++ b/grizzly_cli/distributed/build.py @@ -1,13 +1,18 @@ -import os +from __future__ import annotations -from typing import List, cast -from argparse import SUPPRESS, Namespace as Arguments +import os +from argparse import SUPPRESS +from argparse import Namespace as Arguments from getpass import getuser -from socket import gethostbyname, gaierror +from pathlib import Path +from socket import gaierror, gethostbyname +from typing import TYPE_CHECKING -from grizzly_cli.utils import get_dependency_versions, requirements, run_command -from grizzly_cli.argparse import ArgumentSubParser from grizzly_cli import EXECUTION_CONTEXT, PROJECT_NAME, STATIC_CONTEXT +from grizzly_cli.utils import get_dependency_versions, requirements, run_command + +if TYPE_CHECKING: # pragma: no cover + from grizzly_cli.argparse import ArgumentSubParser def create_parser(sub_parser: ArgumentSubParser) -> None: @@ -63,33 +68,27 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: def getuid() -> int: if os.name == 'nt' or not hasattr(os, 'getuid'): return 1000 - else: - return cast(int, getattr(os, 'getuid')()) + + return os.getuid() def getgid() -> int: if os.name == 'nt' or not hasattr(os, 'getgid'): return 1000 - else: - return cast(int, getattr(os, 'getgid')()) + + return os.getuid() -def _create_build_command(args: Arguments, containerfile: str, tag: str, context: str) -> List[str]: +def _create_build_command(args: Arguments, containerfile: str, tag: str, context: str) -> list[str]: local_install = getattr(args, 'local_install', False) - if local_install: - install_type = 'local' - else: - install_type = 'remote' + install_type = 'local' if local_install else 'remote' - (_, grizzly_extras, ), _ = get_dependency_versions(local_install) + (_, grizzly_extras), _ = get_dependency_versions(local_install=local_install) - if grizzly_extras is not None and 'mq' in grizzly_extras: - grizzly_extra = 'mq' - else: - grizzly_extra = 'base' + grizzly_extra = 'mq' if grizzly_extras is not None and 'mq' in grizzly_extras else 'base' - extra_args: List[str] = [] + extra_args: list[str] = [] ibm_mq_lib_host = os.environ.get('IBM_MQ_LIB_HOST', None) if ibm_mq_lib_host is not None: @@ -120,7 +119,7 @@ def _create_build_command(args: Arguments, containerfile: str, tag: str, context *extra_args, '-f', containerfile, '-t', tag, - context + context, ] @@ -128,14 +127,11 @@ def _create_build_command(args: Arguments, containerfile: str, tag: str, context def build(args: Arguments) -> int: tag = getuser() - if args.project_name is None: - image_name = f'{PROJECT_NAME}:{tag}' - else: - image_name = f'{args.project_name}:{tag}' + image_name = f'{PROJECT_NAME}:{tag}' if args.project_name is None else f'{args.project_name}:{tag}' build_command = _create_build_command( args, - f'{STATIC_CONTEXT}{os.path.sep}Containerfile', + Path.joinpath(Path(STATIC_CONTEXT), 'Containerfile').as_posix(), image_name, EXECUTION_CONTEXT, ) @@ -171,8 +167,8 @@ def build(args: Arguments) -> int: if result.return_code != 0: print(f'\n!! failed to tag image {image_name} -> {args.registry}{image_name}') return result.return_code - else: - print(f'tagged image {image_name} -> {args.registry}{image_name}') + + print(f'tagged image {image_name} -> {args.registry}{image_name}') push_command = [ f'{args.container_system}', diff --git a/grizzly_cli/distributed/clean.py b/grizzly_cli/distributed/clean.py index 29f29b4..0a6e702 100644 --- a/grizzly_cli/distributed/clean.py +++ b/grizzly_cli/distributed/clean.py @@ -1,19 +1,25 @@ -from os import environ -from tempfile import NamedTemporaryFile -from argparse import Namespace as Arguments +from __future__ import annotations + from getpass import getuser +from os import environ from shutil import get_terminal_size +from tempfile import NamedTemporaryFile +from typing import TYPE_CHECKING -from grizzly_cli.argparse import ArgumentSubParser -from grizzly_cli.utils import run_command from grizzly_cli import PROJECT_NAME, STATIC_CONTEXT +from grizzly_cli.utils import run_command + +if TYPE_CHECKING: # pragma: no cover + from argparse import Namespace as Arguments + + from grizzly_cli.argparse import ArgumentSubParser def create_parser(sub_parser: ArgumentSubParser) -> None: # grizzly-cli dist clean ... clean_parser = sub_parser.add_parser('clean', description=( 'clean all grizzly compose project resources; containers, images, networks and volumes' - ),) + )) clean_parser.add_argument( '--no-images', @@ -41,10 +47,7 @@ def clean(args: Arguments) -> int: suffix = '' if args.id is None else f'-{args.id}' tag = getuser() - if args.project_name is not None: - project_name = args.project_name - else: - project_name = PROJECT_NAME + project_name = args.project_name if args.project_name is not None else PROJECT_NAME columns, lines = get_terminal_size() env = environ.copy() @@ -72,7 +75,7 @@ def clean(args: Arguments) -> int: if args.images: command = [ args.container_system, - 'image', 'rm', f'{project_name}:{tag}' + 'image', 'rm', f'{project_name}:{tag}', ] run_command(command) diff --git a/grizzly_cli/init.py b/grizzly_cli/init.py index 3f489e0..af585df 100644 --- a/grizzly_cli/init.py +++ b/grizzly_cli/init.py @@ -1,12 +1,18 @@ -from typing import Generator -from argparse import Namespace as Arguments -from os import path +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING + from packaging.version import Version -from .utils import ask_yes_no -from .argparse import ArgumentSubParser -from . import EXECUTION_CONTEXT, register_parser +from grizzly_cli import EXECUTION_CONTEXT, register_parser +from grizzly_cli.utils import ask_yes_no + +if TYPE_CHECKING: # pragma: no cover + from argparse import Namespace as Arguments + from collections.abc import Generator + + from grizzly_cli.argparse import ArgumentSubParser # prefix components: space = ' ' @@ -35,7 +41,7 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: type=str, required=False, default=None, - help='specify which grizzly version to use for project, default is latest' + help='specify which grizzly version to use for project, default is latest', ) init_parser.add_argument( @@ -59,13 +65,14 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: def tree(dir_path: Path, prefix: str = '') -> Generator[str, None, None]: - '''A recursive generator, given a directory Path object + """Recursive generator, given a directory Path object will yield a visual tree structure line by line - with each line prefixed by the same characters + with each line prefixed by the same characters. credit: https://stackoverflow.com/a/59109706 - ''' - contents = sorted(list(dir_path.iterdir())) + + """ + contents = sorted(dir_path.iterdir()) # contents each get pointers that are ├── with a final └── : pointers = [tee] * (len(contents) - 1) + [last] for pointer, sub_path in zip(pointers, contents): @@ -77,18 +84,18 @@ def tree(dir_path: Path, prefix: str = '') -> Generator[str, None, None]: def init(args: Arguments) -> int: - if path.exists(path.join(EXECUTION_CONTEXT, args.project)): + if Path.joinpath(Path(EXECUTION_CONTEXT), args.project).exists(): print(f'"{args.project}" already exists in {EXECUTION_CONTEXT}') return 1 - if all([path.exists(path.join(EXECUTION_CONTEXT, p)) for p in ['environments', 'features', 'requirements.txt']]): + if all(Path.joinpath(Path(EXECUTION_CONTEXT), p).exists() for p in ['environments', 'features', 'requirements.txt']): print('oops, looks like you are already in a grizzly project directory', end='\n\n') print(EXECUTION_CONTEXT) for line in tree(Path(EXECUTION_CONTEXT)): print(line) return 1 - layout = f''' + layout = f""" {args.project} ├── environments │ └── {args.project}.yaml @@ -99,7 +106,7 @@ def init(args: Arguments) -> int: │ ├── {args.project}.feature │ └── requests └── requirements.txt -''' +""" message = f'the following structure will be created:\n{layout}' @@ -109,7 +116,7 @@ def init(args: Arguments) -> int: print(message) # create project root - structure = Path(path.join(EXECUTION_CONTEXT, args.project)) + structure = Path.joinpath(Path(EXECUTION_CONTEXT), args.project) structure.mkdir() # create requirements.txt @@ -128,20 +135,20 @@ def init(args: Arguments) -> int: structure_environments.mkdir() # create environments/.yaml - (structure_environments / f'{args.project}.yaml').write_text('''configuration: + (structure_environments / f'{args.project}.yaml').write_text("""configuration: template: host: https://localhost -''') +""") # create features/ directory structure_features = structure / 'features' structure_features.mkdir() # create features/.feature - (structure_features / f'{args.project}.feature').write_text('''Feature: Template feature file + (structure_features / f'{args.project}.feature').write_text("""Feature: Template feature file Scenario: Template scenario Given a user of type "RestApi" with weight "1" load testing "$conf::template.host" -''') +""") # create features/environment.py if args.grizzly_version is not None: diff --git a/grizzly_cli/keyvault.py b/grizzly_cli/keyvault.py index 9910f63..d701715 100644 --- a/grizzly_cli/keyvault.py +++ b/grizzly_cli/keyvault.py @@ -2,23 +2,27 @@ import logging import re - -from typing import Any -from pathlib import Path -from argparse import Namespace as Arguments, ArgumentParser as CoreArgumentParser from base64 import b64encode -from dataclasses import dataclass from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any import yaml -from azure.core.exceptions import ResourceNotFoundError, ClientAuthenticationError, ServiceRequestError -from azure.keyvault.secrets import SecretClient +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError, ServiceRequestError -from . import register_parser -from .argparse import ArgumentSubParser -from .argparse.bashcompletion import BashCompletionTypes -from .utils import flatten, unflatten, IndentDumper, merge_dicts, logger, chunker -from .utils.configuration import get_context_root, load_configuration_file, load_configuration_keyvault, get_keyvault_client +from grizzly_cli import register_parser +from grizzly_cli.argparse.bashcompletion import BashCompletionTypes +from grizzly_cli.utils import IndentDumper, chunker, flatten, logger, merge_dicts, unflatten +from grizzly_cli.utils.configuration import get_context_root, get_keyvault_client, load_configuration_file, load_configuration_keyvault + +if TYPE_CHECKING: # pragma: no cover + from argparse import ArgumentParser as CoreArgumentParser + from argparse import Namespace as Arguments + + from azure.keyvault.secrets import SecretClient + + from grizzly_cli.argparse import ArgumentSubParser # disable azure.identity warning logs if authentication fails azure_logger = logging.getLogger('azure') @@ -32,9 +36,6 @@ @dataclass class KeyvaultSecretHolder: - """ - Keyvault secret holder. - """ name: str content_type: str | None value: str @@ -167,7 +168,7 @@ def encode_mq_certificate(root: Path, environment: str, key: str, base_cert_name name=key, content_type='files', value=','.join(references), - ) + ), ) return secrets @@ -222,7 +223,7 @@ def _should_export(key: str, secret: Any) -> bool: return False for keyword in KEYWORDS: - key_should = keyword in key.lower() and not f'.{keyword}.' in key + key_should = keyword in key.lower() and f'.{keyword}.' not in key secret_should = isinstance(secret, str) and keyword in secret.lower() and secret not in COMMON_FALSE_POSITIVES # if key should be exported, but not the secret and the secret is not a string... it shouldn't @@ -242,14 +243,14 @@ def _build_key_name(environment: str, key: str) -> str: return f'grizzly--{environment}--{_keyvault_normalize(key)}' -def _dict_to_yaml(file: Path, content: dict[str, Any], *, indentation: Path | int) -> None: +def _dict_to_yaml(file: Path, content: dict, *, indentation: Path | int) -> None: file.write_text('') # make sure file is empty with file.open('w') as fd: yaml.dump(content, fd, Dumper=IndentDumper.use_indentation(indentation), default_flow_style=False, sort_keys=False, allow_unicode=True) -def _extract_metadata(env_file: str) -> tuple[str, str | None, dict[str, Any]]: +def _extract_metadata(env_file: str) -> tuple[str, str | None, dict]: file = Path(env_file) configuration = load_configuration_file(file).get('configuration', {}) @@ -267,10 +268,12 @@ def diff(left_file_name: str, right_file_name: str) -> int: right_config_file = Path(right_file_name) if not left_config_file.exists(): - raise ValueError(f'environment configuration file {left_config_file.as_posix()} does not exist') + message = f'environment configuration file {left_config_file.as_posix()} does not exist' + raise ValueError(message) if not right_config_file.exists(): - raise ValueError(f'environment configuration file {right_config_file.as_posix()} does not exist') + message = f'environment configuration file {right_config_file.as_posix()} does not exist' + raise ValueError(message) left_config = flatten(load_configuration_file(right_config_file)['configuration']) right_config = flatten(load_configuration_file(left_config_file)['configuration']) @@ -287,15 +290,15 @@ def diff(left_file_name: str, right_file_name: str) -> int: diffed_keys.add(key) for key, value in right_config.items(): - if key not in left_config or left_config.get(key, None) != value and key not in diffed_keys: + if key not in left_config or (left_config.get(key, None) != value and key not in diffed_keys): logger.error(f'+ {key}: {left_config.get(key, None)} != {value}') return 0 -def keyvault_import(client: SecretClient, environment: str, args: Arguments, root: Path, configuration: dict[str, Any]) -> int: +def keyvault_import(client: SecretClient, environment: str, args: Arguments, root: Path, configuration: dict) -> int: # unflatten existing configuration - configuration_unflatten: dict[str, Any] = {} + configuration_unflatten: dict = {} original_key_count = len(configuration) @@ -336,45 +339,57 @@ def keyvault_import(client: SecretClient, environment: str, args: Arguments, roo return 0 -def keyvault_export(client: SecretClient, environment: str, args: Arguments, root: Path, configuration: dict[str, Any]) -> int: - """ - From grizzly to keyvault. +def upsert_secrets(client: SecretClient, secrets: list[KeyvaultSecretHolder], *, dry_run: bool) -> int: + created_secrets_count = 0 - If the specified secret starts with `cert:`, it indicates that it should reference a keyvault certificate (the name). This value - can have metadata (secret content type) appended after the actual value the `#` separator. If the output certificate should be - password protected, `pass:` should reference a keyvault secret which contains the password. + for secret in secrets: + # check if secret already exists + try: + current_value = client.get_secret(secret.name) + if current_value.value != secret.value or current_value.properties.content_type != secret.content_type: + raise ResourceNotFoundError - `cert:[,pass:][#format:[mqm|pem-public|pem-private]]` + # it exists with the same value, skip + continue + except ResourceNotFoundError: # great, import it into the keyvault + pass - Supported certificate output formats: - - `mqm`, MQ CMS keystore - - `pem-public`, public certificate in PEM format - - `pem-private`, private key in PEM format + logger.debug(f'% keyvault secret {secret.name} with content type {secret.content_type}') - If the configuration key contains `file` in the path, the configuration value will be base64 encoded and, optionally, chunked into - keyvault secrets. If the path also contains `mq`, all MQ keystore/CMS files will be encoded, chunked and then references to the - actual configuration key. - """ - secrets: list[KeyvaultSecretHolder] = [] + if not dry_run: + client.set_secret(secret.name, secret.value, content_type=secret.content_type) - environment_file = Path(args.env_file) + created_secrets_count += 1 + + return created_secrets_count - safe_configuration = {} + +def prepare_secrets( + client: SecretClient, + environment: str, + root: Path, + configuration: dict, + *, + filter_keys: list[str] | None, + global_configuration: list[str], +) -> tuple[dict, list[KeyvaultSecretHolder]]: + secrets: list[KeyvaultSecretHolder] = [] + safe_configuration: dict = {} for key, secret in configuration.items(): if not _should_export(key, secret): safe_configuration.update({key: secret}) continue - if args.keys is not None and key not in args.keys: + if filter_keys is not None and key not in filter_keys: continue - key_environment = _determine_environment(args.global_configuration, environment, key) + key_environment = _determine_environment(global_configuration, environment, key) key_name = _build_key_name(key_environment, key) if secret.startswith('cert:'): if '#' in secret: - secret, content_type = secret.split('#', 1) + secret, content_type = secret.split('#', 1) # noqa: PLW2901 else: content_type = None @@ -385,7 +400,7 @@ def keyvault_export(client: SecretClient, environment: str, args: Arguments, roo client.get_secret(password_key) except ResourceNotFoundError: message = f'key {password_key} referenced in value for {key} does not exist' - raise ValueError(message) + raise ValueError(message) from None secrets.append(KeyvaultSecretHolder( name=key_name, @@ -404,32 +419,37 @@ def keyvault_export(client: SecretClient, environment: str, args: Arguments, roo value=secret, )) - created_secrets_count = 0 + return safe_configuration, secrets - for secret in secrets: - # check if secret already exists - try: - current_value = client.get_secret(secret.name) - if current_value.value != secret.value or current_value.properties.content_type != secret.content_type: - raise ResourceNotFoundError - # it exists with the same value, skip - continue - except ResourceNotFoundError: # great, import it into the keyvault - pass +def keyvault_export(client: SecretClient, environment: str, args: Arguments, root: Path, configuration: dict) -> int: + """From grizzly to keyvault. - logger.debug(f'% keyvault secret {secret.name} with content type {secret.content_type}') + If the specified secret starts with `cert:`, it indicates that it should reference a keyvault certificate (the name). This value + can have metadata (secret content type) appended after the actual value the `#` separator. If the output certificate should be + password protected, `pass:` should reference a keyvault secret which contains the password. - if not args.dry_run: - client.set_secret(secret.name, secret.value, content_type=secret.content_type) + `cert:[,pass:][#format:[mqm|pem-public|pem-private]]` - created_secrets_count += 1 + Supported certificate output formats: + - `mqm`, MQ CMS keystore + - `pem-public`, public certificate in PEM format + - `pem-private`, private key in PEM format + + If the configuration key contains `file` in the path, the configuration value will be base64 encoded and, optionally, chunked into + keyvault secrets. If the path also contains `mq`, all MQ keystore/CMS files will be encoded, chunked and then references to the + actual configuration key. + """ + environment_file = Path(args.env_file) + safe_configuration, secrets = prepare_secrets(client, environment, root, configuration, filter_keys=args.keys, global_configuration=args.global_configuration) + + created_secrets_count = upsert_secrets(client, secrets, dry_run=args.dry_run) already_exists = len(secrets) - created_secrets_count if not args.dry_run: unsafe_environment_file = environment_file.rename(environment_file.with_suffix(f'.unsafe{environment_file.suffix}')) - safe_yaml_configuration: dict[str, Any] = {} + safe_yaml_configuration: dict = {} for key, value in safe_configuration.items(): safe_yaml_configuration = merge_dicts(safe_yaml_configuration, unflatten(key, value)) @@ -446,7 +466,7 @@ def keyvault_export(client: SecretClient, environment: str, args: Arguments, roo logger.info( f'created {created_secrets_count} ({already_exists} already existed) secrets in keyvault {client.vault_url} ' - f'and saved the safe environment configuration in {environment_file.as_posix()}' + f'and saved the safe environment configuration in {environment_file.as_posix()}', ) logger.warning(f'! the unsafe environment configuration is still present in {unsafe_environment_file.as_posix()}') @@ -465,7 +485,8 @@ def keyvault(args: Arguments) -> int: env_file = Path(args.env_file) if args.subcommand == 'import' and not env_file.exists(): if args.keyvault is None: - raise ValueError(f'--vault-name not specified and environment configuration file {args.env_file} does not exist') + message = f'--vault-name not specified and environment configuration file {args.env_file} does not exist' + raise ValueError(message) _dict_to_yaml(env_file, {'configuration': {'keyvault': args_keyvault}}, indentation=2) @@ -481,19 +502,23 @@ def keyvault(args: Arguments) -> int: keyvault = keyvault or args_keyvault if keyvault is None: - raise ValueError('keyvault not specified, please specify a keyvault') + message = 'keyvault not specified, please specify a keyvault' + raise ValueError(message) client = get_keyvault_client(keyvault) try: if args.subcommand == 'import': # from keyvault return keyvault_import(client, environment, args, grizzly_context_root, configuration) - elif args.subcommand == 'export': # to keyvault + + if args.subcommand == 'export': # to keyvault return keyvault_export(client, environment, args, grizzly_context_root, configuration) - elif args.subcommand == 'diff': + + if args.subcommand == 'diff': return diff(args.env_file, args.orig_file) - else: - raise ValueError(f'unknown subcommand {args.subcommand}') + + message = f'unknown subcommand {args.subcommand}' + raise ValueError(message) except ClientAuthenticationError: logger.error('authentication failed, if you are running from a resource which does not have a managed identity then you must run `az login` first.') return 1 diff --git a/grizzly_cli/local.py b/grizzly_cli/local.py index e41b42f..1cd16dd 100644 --- a/grizzly_cli/local.py +++ b/grizzly_cli/local.py @@ -1,14 +1,13 @@ import os - -from typing import List, Dict, Any from argparse import Namespace as Arguments -from . import register_parser -from .utils import ( +from grizzly_cli import register_parser +from grizzly_cli.argparse import ArgumentSubParser +from grizzly_cli.run import create_parser as run_create_parser +from grizzly_cli.run import run +from grizzly_cli.utils import ( run_command, ) -from .run import create_parser as run_create_parser, run -from .argparse import ArgumentSubParser @register_parser(order=2) @@ -26,11 +25,12 @@ def create_parser(sub_parser: ArgumentSubParser) -> None: def local(args: Arguments) -> int: if args.subcommand == 'run': return run(args, local_run) - else: - raise ValueError(f'unknown subcommand {args.subcommand}') + + message = f'unknown subcommand {args.subcommand}' + raise ValueError(message) -def local_run(args: Arguments, environ: Dict[str, Any], run_arguments: Dict[str, List[str]]) -> int: +def local_run(args: Arguments, environ: dict, run_arguments: dict[str, list[str]]) -> int: for key, value in environ.items(): if key not in os.environ: os.environ[key] = value diff --git a/grizzly_cli/run.py b/grizzly_cli/run.py index 14a2562..05f0da4 100644 --- a/grizzly_cli/run.py +++ b/grizzly_cli/run.py @@ -1,37 +1,39 @@ from __future__ import annotations -import sys import os - +import sys +from contextlib import suppress +from datetime import datetime +from pathlib import Path +from platform import node as get_hostname from typing import ( - List, - Dict, - Any, + TYPE_CHECKING, Callable, TextIO, cast, ) -from argparse import Namespace as Arguments -from platform import node as get_hostname -from datetime import datetime -from pathlib import Path -from contextlib import suppress + from jinja2 import Environment import grizzly_cli -from .utils import ( - logger, - find_variable_names_in_questions, - ask_yes_no, get_input, +from grizzly_cli.argparse.bashcompletion import BashCompletionTypes +from grizzly_cli.utils import ( + ask_yes_no, distribution_of_users_per_scenario, - requirements, find_metadata_notices, + find_variable_names_in_questions, + get_input, + logger, parse_feature_file, + requirements, rm_rf, ) -from .utils.configuration import ScenarioTag, load_configuration, get_context_root -from .argparse import ArgumentSubParser -from .argparse.bashcompletion import BashCompletionTypes +from grizzly_cli.utils.configuration import ScenarioTag, get_context_root, load_configuration + +if TYPE_CHECKING: + from argparse import Namespace as Arguments + + from grizzly_cli.argparse import ArgumentSubParser def create_parser(sub_parser: ArgumentSubParser, parent: str) -> None: @@ -44,7 +46,7 @@ def create_parser(sub_parser: ArgumentSubParser, parent: str) -> None: help=( 'changes the log level to `DEBUG`, regardless of what it says in the feature file. gives more verbose logging ' 'that can be useful when troubleshooting a problem with a scenario.' - ) + ), ) run_parser.add_argument( '-T', '--testdata-variable', @@ -53,7 +55,7 @@ def create_parser(sub_parser: ArgumentSubParser, parent: str) -> None: required=False, help=( 'specified in the format `=`. avoids being asked for an initial value for a scenario variable.' - ) + ), ) run_parser.add_argument( '-y', '--yes', @@ -132,10 +134,91 @@ def create_parser(sub_parser: ArgumentSubParser, parent: str) -> None: run_parser.prog = f'grizzly-cli {parent} run' +def should_prompt_questions(args: Arguments, environ: dict) -> None: + variables = find_variable_names_in_questions(args.file) + questions = len(variables) + manual_input = False + + if questions > 0 and not getattr(args, 'validate_config', False): + logger.info(f'feature file requires values for {questions} variables') + + for variable in variables: + name = f'TESTDATA_VARIABLE_{variable}' + value = os.environ.get(name, '') + while len(value) < 1: + value = get_input(f'initial value for "{variable}": ') + manual_input = True + + environ[name] = value + + logger.info('the following values was provided:') + for key, value in environ.items(): + if not key.startswith('TESTDATA_VARIABLE_'): + continue + logger.info(f'{key.replace("TESTDATA_VARIABLE_", "")} = {value}') + + if manual_input: + ask_yes_no('continue?') + + +def should_prompt_notices(args: Arguments) -> None: + notices = find_metadata_notices(args.file) + + if len(notices) > 0: + output_func = cast('Callable[[str], None]', logger.info) if args.yes else ask_yes_no + + for notice in notices: + output_func(notice) + + +def update_grizzly_environment(args: Arguments, environ: dict) -> None: + if args.environment_file is not None: + environment_lock_file = load_configuration(Path(args.environment_file).resolve()) + environ.update({'GRIZZLY_CONFIGURATION_FILE': environment_lock_file.as_posix()}) + + if args.dry_run: + environ.update({'GRIZZLY_DRY_RUN': 'true'}) + + if args.log_dir is not None: + environ.update({'GRIZZLY_LOG_DIR': args.log_dir}) + + +def build_run_arguments(args: Arguments) -> dict[str, list[str]]: + run_arguments: dict[str, list[str]] = { + 'master': [], + 'worker': [], + 'common': [], + } + + if args.verbose: + run_arguments['common'] += ['--verbose', '--no-logcapture', '--no-capture', '--no-capture-stderr'] + + if args.csv_prefix is not None: + if args.csv_prefix is True: + parse_feature_file(args.file) + if grizzly_cli.FEATURE_DESCRIPTION is None: + message = 'feature file does not seem to have a `Feature:` description to use as --csv-prefix' + raise ValueError(message) + + csv_prefix = grizzly_cli.FEATURE_DESCRIPTION.replace(' ', '_') + timestamp = datetime.now().astimezone().strftime('%Y%m%dT%H%M%S') + args.csv_prefix = f'{csv_prefix}_{timestamp}' + + run_arguments['common'] += [f'-Dcsv-prefix="{args.csv_prefix}"'] + + if args.csv_interval is not None: + run_arguments['common'] += [f'-Dcsv-interval={args.csv_interval}'] + + if args.csv_flush_interval is not None: + run_arguments['common'] += [f'-Dcsv-flush-interval={args.csv_flush_interval}'] + + return run_arguments + + @requirements(grizzly_cli.EXECUTION_CONTEXT) -def run(args: Arguments, run_func: Callable[[Arguments, Dict[str, Any], Dict[str, List[str]]], int]) -> int: +def run(args: Arguments, run_func: Callable[[Arguments, dict, dict[str, list[str]]], int]) -> int: # always set hostname of host where grizzly-cli was executed, could be useful - environ: Dict[str, Any] = { + environ: dict = { 'GRIZZLY_CLI_HOST': get_hostname(), 'GRIZZLY_EXECUTION_CONTEXT': grizzly_cli.EXECUTION_CONTEXT, 'GRIZZLY_MOUNT_CONTEXT': grizzly_cli.MOUNT_CONTEXT, @@ -150,7 +233,7 @@ def run(args: Arguments, run_func: Callable[[Arguments, Dict[str, Any], Dict[str feature_lock_file = feature_file.parent / f'{feature_file.stem}.lock{feature_file.suffix}' try: - buffer: List[str] = [] + buffer: list[str] = [] remove_endif = False # remove if-statements containing variables (`{$ .. $}`) @@ -176,94 +259,27 @@ def run(args: Arguments, run_func: Callable[[Arguments, Dict[str, Any], Dict[str feature_lock_file.write_text(feature_content) if args.dump: - output: TextIO - if isinstance(args.dump, str): - output = Path(args.dump).open('w+') - else: - output = sys.stdout + output: TextIO = Path(args.dump).open('w+') if isinstance(args.dump, str) else sys.stdout # noqa: SIM115 - print(feature_content, file=output) + try: + print(feature_content, file=output) + finally: + # do not close stdout... + if output is not sys.stdout: + output.close() return 0 args.file = feature_lock_file.as_posix() - variables = find_variable_names_in_questions(args.file) - questions = len(variables) - manual_input = False - - if questions > 0 and not getattr(args, 'validate_config', False): - logger.info(f'feature file requires values for {questions} variables') - - for variable in variables: - name = f'TESTDATA_VARIABLE_{variable}' - value = os.environ.get(name, '') - while len(value) < 1: - value = get_input(f'initial value for "{variable}": ') - manual_input = True - - environ[name] = value - - logger.info('the following values was provided:') - for key, value in environ.items(): - if not key.startswith('TESTDATA_VARIABLE_'): - continue - logger.info(f'{key.replace("TESTDATA_VARIABLE_", "")} = {value}') - - if manual_input: - ask_yes_no('continue?') - - notices = find_metadata_notices(args.file) - - if len(notices) > 0: - if args.yes: - output_func = cast(Callable[[str], None], logger.info) - else: - output_func = ask_yes_no - - for notice in notices: - output_func(notice) - - if args.environment_file is not None: - environment_file = os.path.realpath(args.environment_file) - environment_lock_file = load_configuration(environment_file) - environ.update({'GRIZZLY_CONFIGURATION_FILE': environment_lock_file}) - - if args.dry_run: - environ.update({'GRIZZLY_DRY_RUN': 'true'}) - - if args.log_dir is not None: - environ.update({'GRIZZLY_LOG_DIR': args.log_dir}) + should_prompt_questions(args, environ) + should_prompt_notices(args) + update_grizzly_environment(args, environ) if not getattr(args, 'validate_config', False): distribution_of_users_per_scenario(args, environ) - run_arguments: Dict[str, List[str]] = { - 'master': [], - 'worker': [], - 'common': [], - } - - if args.verbose: - run_arguments['common'] += ['--verbose', '--no-logcapture', '--no-capture', '--no-capture-stderr'] - - if args.csv_prefix is not None: - if args.csv_prefix is True: - parse_feature_file(args.file) - if grizzly_cli.FEATURE_DESCRIPTION is None: - raise ValueError('feature file does not seem to have a `Feature:` description to use as --csv-prefix') - - csv_prefix = grizzly_cli.FEATURE_DESCRIPTION.replace(' ', '_') - timestamp = datetime.now().astimezone().strftime('%Y%m%dT%H%M%S') - setattr(args, 'csv_prefix', f'{csv_prefix}_{timestamp}') - - run_arguments['common'] += [f'-Dcsv-prefix="{args.csv_prefix}"'] - - if args.csv_interval is not None: - run_arguments['common'] += [f'-Dcsv-interval={args.csv_interval}'] - - if args.csv_flush_interval is not None: - run_arguments['common'] += [f'-Dcsv-flush-interval={args.csv_flush_interval}'] + run_arguments = build_run_arguments(args) return run_func(args, environ, run_arguments) finally: diff --git a/grizzly_cli/utils/__init__.py b/grizzly_cli/utils/__init__.py index dad47dd..015e70a 100644 --- a/grizzly_cli/utils/__init__.py +++ b/grizzly_cli/utils/__init__.py @@ -1,49 +1,50 @@ from __future__ import annotations -import re -import sys -import subprocess -import signal as psignal import logging import logging.config -import stat import os - -from typing import Optional, List, Set, Union, Dict, Any, Tuple, Callable, Type, ClassVar, cast -from types import TracebackType, FrameType -from os import path, environ -from shutil import which, rmtree -from behave.parser import parse_file as feature_file_parser -from argparse import Namespace as Arguments -from json import loads as jsonloads +import re +import signal as psignal +import stat +import subprocess +import sys +from collections.abc import Mapping +from contextlib import suppress +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import datetime, timezone from functools import wraps -from packaging import version as versioning -from tempfile import mkdtemp from hashlib import sha1 +from json import loads as jsonloads from math import ceil -from datetime import datetime, timezone -from dataclasses import dataclass, field +from os import environ from pathlib import Path -from copy import deepcopy -from collections.abc import Mapping +from shutil import rmtree, which +from tempfile import mkdtemp +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Union, cast import requests import tomli - -from behave.model import Scenario +from behave.parser import parse_file as feature_file_parser from jinja2 import Template +from packaging import version as versioning from progress.spinner import Spinner from yaml import Dumper import grizzly_cli +if TYPE_CHECKING: # pragma: no cover + from argparse import Namespace as Arguments + from types import FrameType, TracebackType + + from behave.model import Scenario logger = logging.getLogger('grizzly-cli') class SignalHandler: handler: Callable[[int, Optional[FrameType]], None] - signals: Dict[int, Union[Callable[[int, Optional[FrameType]], Any], int, None]] + signals: dict[int, Union[Callable[[int, Optional[FrameType]], Any], int, None]] def __init__(self, handler: Callable[[int, Optional[FrameType]], None], signal: int, *signals: int) -> None: self.handler = handler @@ -54,11 +55,11 @@ def __init__(self, handler: Callable[[int, Optional[FrameType]], None], signal: self.signals.update({sig: None}) def __enter__(self) -> None: - for signal in self.signals.keys(): + for signal in self.signals: self.signals.update({signal: psignal.getsignal(signal)}) psignal.signal(signal, self.handler) - def __exit__(self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]) -> bool: + def __exit__(self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]) -> bool: for signal, handler in self.signals.items(): psignal.signal(signal, handler) @@ -69,15 +70,15 @@ def __exit__(self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseEx class RunCommandResult: return_code: int abort_timestamp: Optional[datetime] = field(init=False, default=None) - output: Optional[List[bytes]] = field(init=False, default=None) + output: Optional[list[bytes]] = field(init=False, default=None) -def run_command(command: List[str], env: Optional[Dict[str, str]] = None, *, silent: bool = False, verbose: bool = False, spinner: Optional[str] = None) -> RunCommandResult: +def run_command(command: list[str], env: Optional[dict[str, str]] = None, *, silent: bool = False, verbose: bool = False, spinner: Optional[str] = None) -> RunCommandResult: if env is None: env = environ.copy() if verbose: - logger.info(f'run_command: {" ".join(command)}') + logger.info('run_command: %s', ' '.join(command)) process = subprocess.Popen( command, @@ -96,7 +97,7 @@ def run_command(command: List[str], env: Optional[Dict[str, str]] = None, *, sil if spinner is not None: # pragma: no cover _spinner = Spinner(f'{spinner} ') - def sig_handler(signum: int, frame: Optional[FrameType] = None) -> None: # pragma: no cover + def sig_handler(*_args: Any, **_kwargs: Any) -> None: # pragma: no cover if result.abort_timestamp is None: result.abort_timestamp = datetime.now(timezone.utc) process.terminate() @@ -125,10 +126,8 @@ def sig_handler(signum: int, frame: Optional[FrameType] = None) -> None: # prag except KeyboardInterrupt: pass finally: - try: + with suppress(Exception): process.kill() - except Exception: - pass process.wait() @@ -140,25 +139,21 @@ def sig_handler(signum: int, frame: Optional[FrameType] = None) -> None: # prag return result -def get_docker_compose_version() -> Tuple[int, int, int]: # pragma: no cover - output = subprocess.getoutput('docker compose version') +def get_docker_compose_version() -> tuple[int, int, int]: # pragma: no cover + output = subprocess.getoutput('docker compose version') # noqa: S605 version_line = output.splitlines()[0] match = re.match(r'.*version [v]?([1-2]\.[0-9]+\.[0-9]+).*$', version_line) - if match: - version = cast(Tuple[int, int, int], tuple([int(part) for part in match.group(1).split('.')])) - else: - version = (0, 0, 0,) - - return version + return cast('tuple[int, int, int]', tuple([int(part) for part in match.group(1).split('.')])) if match else (0, 0, 0) -def onerror(func: Callable, path: str, exc_info: Union[ - BaseException, - Tuple[Type[BaseException], BaseException, Optional[TracebackType]], -]) -> None: # noqa: ARG001 # pragma: no cover +def onerror( + func: Callable, + path: str, + exc_info: Union[BaseException, tuple[type[BaseException], BaseException, Optional[TracebackType]], # noqa: ARG001 +]) -> None: # pragma: no cover """Error handler for shutil.rmtree. If the error is due to an access error (read only file) @@ -172,12 +167,13 @@ def onerror(func: Callable, path: str, exc_info: Union[ _path.chmod(stat.S_IWUSR) func(path) else: - raise # pylint: disable=E0704 + raise # noqa: PLE0704 def rm_rf(path: Union[str, Path], *, missing_ok: bool = False) -> None: """Remove the path contents recursively, even if some elements - are read-only.""" + are read-only. + """ p = path.as_posix() if isinstance(path, Path) else path try: @@ -190,14 +186,14 @@ def rm_rf(path: Union[str, Path], *, missing_ok: bool = False) -> None: raise -def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Optional[str], Optional[List[str]]], Optional[str]]: +def get_dependency_versions(*, local_install: Union[bool, str]) -> tuple[tuple[Optional[str], Optional[list[str]]], Optional[str]]: # noqa: C901, PLR0912, PLR0915 grizzly_requirement: Optional[str] = None grizzly_requirement_egg: str locust_version: Optional[str] = None grizzly_version: Optional[str] = None - grizzly_extras: Optional[List[str]] = None + grizzly_extras: Optional[list[str]] = None - args: tuple[str, ...] = (grizzly_cli.EXECUTION_CONTEXT,) + args: tuple[str, ...] = () if isinstance(local_install, str): args += (local_install,) if not local_install.endswith('requirements.txt'): @@ -205,20 +201,20 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti else: args += ('requirements.txt',) - project_requirements = path.join(*args) + project_requirements = Path.joinpath(Path(grizzly_cli.EXECUTION_CONTEXT), *args) try: - with open(project_requirements, encoding='utf-8') as fd: + with project_requirements.open(encoding='utf-8') as fd: for line in fd.readlines(): - if any([pkg in line for pkg in ['grizzly-loadtester', 'grizzly.git'] if not re.match(r'^([\s]+)?#', line)]): + if any(pkg in line for pkg in ['grizzly-loadtester', 'grizzly.git'] if not re.match(r'^([\s]+)?#', line)): grizzly_requirement = line.strip() break except: - return (None, None,), None + return (None, None), None if grizzly_requirement is None: print(f'!! unable to find grizzly dependency in {project_requirements}', file=sys.stderr) - return ('(unknown)', None, ), '(unknown)' + return ('(unknown)', None), '(unknown)' # check if it's a repo or not if 'git+' in grizzly_requirement: @@ -231,24 +227,24 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti url = url.strip() else: print(f'!! unable to find properly formatted grizzly dependency in {project_requirements}', file=sys.stderr) - return ('(unknown)', None, ), '(unknown)' + return ('(unknown)', None), '(unknown)' url, branch = url.rsplit('@', 1) url = url[4:] # remove git+ - suffix = sha1(grizzly_requirement.encode('utf-8')).hexdigest() + suffix = sha1(grizzly_requirement.encode('utf-8')).hexdigest() # noqa: S324 # extras_requirement normalization egg = grizzly_requirement_egg.replace('[', '__').replace(']', '__').replace(',', '_') tmp_workspace = mkdtemp(prefix='grizzly-cli-') - repo_destination = path.join(tmp_workspace, f'{egg}_{suffix}') + repo_destination = Path(tmp_workspace) / f'{egg}_{suffix}' try: rc = subprocess.check_call( [ 'git', 'clone', '--filter=blob:none', '-q', url, - repo_destination + repo_destination, ], shell=False, stdout=subprocess.DEVNULL, @@ -257,7 +253,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if rc != 0: print(f'!! unable to clone git repo {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort active_branch = branch @@ -274,7 +270,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if rc != 0: print(f'!! unable to check branch name of HEAD in git repo {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort if active_branch != branch: try: @@ -290,7 +286,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti git_object_type = 'branch' # assume remote branch else: print(f'!! unable to determine git object type for {branch}') - raise RuntimeError() + raise RuntimeError from cpe if git_object_type == 'tag': # pragma: no cover rc += subprocess.check_call( @@ -307,7 +303,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if rc != 0: # pragma: no cover print(f'!! unable to checkout tag {branch} from git repo {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort elif git_object_type == 'commit': rc += subprocess.check_call( [ @@ -322,7 +318,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if rc != 0: # pragma: no cover print(f'!! unable to checkout commit {branch} from git repo {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort else: rc += subprocess.check_call( [ @@ -338,30 +334,30 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if rc != 0: print(f'!! unable to checkout branch {branch} from git repo {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort - if not path.exists(path.join(repo_destination, 'pyproject.toml')): - with open(path.join(repo_destination, 'grizzly', '__init__.py'), encoding='utf-8') as fd: + if not Path.joinpath(repo_destination, 'pyproject.toml').exists(): + with Path.joinpath(repo_destination, 'grizzly', '__init__.py').open(encoding='utf-8') as fd: version_raw = [line.strip() for line in fd.readlines() if line.strip().startswith('__version__ =')] if len(version_raw) != 1: print(f'!! unable to find "__version__" declaration in grizzly/__init__.py from {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort _, grizzly_version, _ = version_raw[-1].split("'") else: try: - with open(path.join(repo_destination, 'setup.cfg'), encoding='utf-8') as fd: + with Path.joinpath(repo_destination, 'setup.cfg').open(encoding='utf-8') as fd: version_raw = [line.strip() for line in fd.readlines() if line.strip().startswith('version = ')] if len(version_raw) != 1: print(f'!! unable to find "version" declaration in setup.cfg from {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort _, grizzly_version = version_raw[-1].split(' = ') except FileNotFoundError: try: - import setuptools_scm # pylint: disable=unused-import # noqa: F401 # type: ignore + import setuptools_scm # noqa: F401, PLC0415 except ModuleNotFoundError: # pragma: no cover rc = subprocess.check_call([ sys.executable, @@ -382,20 +378,20 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti universal_newlines=True, cwd=repo_destination, ).strip() - except subprocess.CalledProcessError: # pragma: no cover + except subprocess.CalledProcessError as e: # pragma: no cover print(f'!! unable to get setuptools_scm version from {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError from e # abort if grizzly_version == '0.0.0': grizzly_version = '(development)' try: - with open(path.join(repo_destination, 'requirements.txt'), encoding='utf-8') as fd: + with Path.joinpath(repo_destination, 'requirements.txt').open(encoding='utf-8') as fd: version_raw = [line.strip() for line in fd.readlines() if line.strip().startswith('locust')] if len(version_raw) != 1: print(f'!! unable to find "locust" dependency in requirements.txt from {url}', file=sys.stderr) - raise RuntimeError() # abort + raise RuntimeError # abort match = re.match(r'^locust.{2}(.*?)$', version_raw[-1].strip().split(' ')[0]) @@ -404,7 +400,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti else: locust_version = match.group(1).strip() except FileNotFoundError: # pragma: no cover - with open(path.join(repo_destination, 'pyproject.toml'), 'rb') as fdt: + with Path.joinpath(repo_destination, 'pyproject.toml').open('rb') as fdt: toml_dict = tomli.load(fdt) dependencies = toml_dict.get('project', {}).get('dependencies', []) for dependency in dependencies: @@ -420,7 +416,8 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti rm_rf(tmp_workspace) else: response = requests.get( - 'https://pypi.org/pypi/grizzly-loadtester/json' + 'https://pypi.org/pypi/grizzly-loadtester/json', + timeout=10, ) if response.status_code != 200: @@ -434,7 +431,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if re.match(r'^grizzly-loadtester(\[[^\]]*\])?$', grizzly_requirement): # latest grizzly_version = pypi.get('info', {}).get('version', None) else: - conditions: List[Callable[[versioning.Version], bool]] = [] + conditions: list[Callable[[versioning.Version], bool]] = [] match = re.match(r'^(grizzly-loadtester(\[[^\]]*\])?)(.*?)$', grizzly_requirement) @@ -457,13 +454,11 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti matched_version = None - for available_version in pypi.get('releases', {}).keys(): - try: + for available_version in pypi.get('releases', {}): + with suppress(versioning.InvalidVersion): version = versioning.parse(available_version) - if len(conditions) > 0 and all([compare(version) for compare in conditions]): + if len(conditions) > 0 and all(compare(version) for compare in conditions): matched_version = version - except versioning.InvalidVersion: - pass if matched_version is None: print(f'!! could not resolve {grizzly_requirement} to one specific version available at pypi', file=sys.stderr) @@ -473,7 +468,8 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti if grizzly_version is not None: # get version from pypi, to be able to get locust version response = requests.get( - f'https://pypi.org/pypi/grizzly-loadtester/{grizzly_version}/json' + f'https://pypi.org/pypi/grizzly-loadtester/{grizzly_version}/json', + timeout=10, ) if response.status_code != 200: @@ -487,10 +483,7 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti match = re.match(r'^locust \((.*?)\)$', requires_dist.strip()) - if match: - locust_version = cast(str, match.group(1)) - else: - locust_version = requires_dist.replace('locust', '').strip() + locust_version = cast('str', match.group(1)) if match else requires_dist.replace('locust', '').strip() if locust_version is not None and locust_version.startswith('=='): locust_version = locust_version[2:] @@ -508,19 +501,16 @@ def get_dependency_versions(local_install: Union[bool, str]) -> Tuple[Tuple[Opti else: match = re.match(r'^grizzly-loadtester\[([^\]]*)\]$', grizzly_requirement_egg) - if match: - grizzly_extras = [extra.strip() for extra in match.group(1).split(',')] - else: - grizzly_extras = [] + grizzly_extras = [extra.strip() for extra in match.group(1).split(',')] if match else [] if locust_version is None: locust_version = '(unknown)' - return (grizzly_version, grizzly_extras, ), locust_version + return (grizzly_version, grizzly_extras), locust_version -def list_images(args: Arguments) -> Dict[str, Any]: - images: Dict[str, Any] = {} +def list_images(args: Arguments) -> dict[str, dict[str, str]]: + images: dict[str, dict[str, str]] = {} output = subprocess.check_output([ f'{args.container_system}', 'image', @@ -542,6 +532,7 @@ def list_images(args: Arguments) -> Dict[str, Any]: if name not in images: images[name] = {} + images[name].update(version) return images @@ -559,7 +550,8 @@ def get_default_mtu(args: Arguments) -> Optional[str]: ]).decode('utf-8') line, _ = output.split('\n', 1) - network_options: Dict[str, str] = jsonloads(line) + network_options: dict[str, str] = jsonloads(line) + return network_options.get('com.docker.network.driver.mtu', '1500') except: return None @@ -568,12 +560,12 @@ def get_default_mtu(args: Arguments) -> Optional[str]: def requirements(execution_context: str) -> Callable[[Callable[..., int]], Callable[..., int]]: def wrapper(func: Callable[..., int]) -> Callable[..., int]: @wraps(func) - def _wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> int: + def _wrapper(*args: Any, **kwargs: Any) -> int: return func(*args, **kwargs) # a bit ugly, but needed for testability - setattr(func, '__value__', execution_context) - setattr(_wrapper, '__wrapped__', func) + setattr(func, '__value__', execution_context) # noqa: B010 + setattr(_wrapper, '__wrapped__', func) # noqa: B010 return _wrapper @@ -590,7 +582,7 @@ def get_distributed_system() -> Optional[str]: print('neither "podman" nor "docker" found in PATH') return None - rc, _ = subprocess.getstatusoutput(f'{container_system} compose version') + rc, _ = subprocess.getstatusoutput(f'{container_system} compose version') # noqa: S605 if rc != 0: print(f'"{container_system} compose" not found in PATH') @@ -611,7 +603,7 @@ def ask_yes_no(question: str) -> None: answer = get_input(f'{question} [y/n]: ') if answer == 'n': - raise KeyboardInterrupt() + raise KeyboardInterrupt def parse_feature_file(file: str) -> None: @@ -626,13 +618,13 @@ def parse_feature_file(file: str) -> None: grizzly_cli.SCENARIOS.append(scenario) -def find_metadata_notices(file: str) -> List[str]: - with open(file) as fd: - return [line.strip().replace('# grizzly-cli:notice ', '') for line in fd.readlines() if line.strip().startswith('# grizzly-cli:notice ')] +def find_metadata_notices(file: str) -> list[str]: + with Path(file).open('r') as fd: + return [line.strip().replace('# grizzly-cli:notice ', '') for line in fd if line.strip().startswith('# grizzly-cli:notice ')] -def find_variable_names_in_questions(file: str) -> List[str]: - unique_variables: Set[str] = set() +def find_variable_names_in_questions(file: str) -> list[str]: + unique_variables: set[str] = set() parse_feature_file(file) @@ -644,84 +636,90 @@ def find_variable_names_in_questions(file: str) -> List[str]: match = re.match(r'ask for value of variable "([^"]*)"', step.name) if not match: - raise ValueError(f'could not find variable name in "{step.name}"') + message = f'could not find variable name in "{step.name}"' + raise ValueError(message) unique_variables.add(match.group(1)) - return sorted(list(unique_variables)) + return sorted(unique_variables) -def distribution_of_users_per_scenario(args: Arguments, environ: Dict[str, Any]) -> None: - def _guess_datatype(value: str) -> Union[str, int, float, bool]: - check_value = value.replace('.', '', 1) +def _guess_datatype(value: str) -> Union[str, int, float, bool]: + check_value = value.replace('.', '', 1) - if check_value[0] == '-': - check_value = check_value[1:] + if check_value[0] == '-': + check_value = check_value[1:] - if check_value.isdecimal(): - if float(value) % 1 == 0: - if value.startswith('0'): - return str(value) - else: - return int(float(value)) - else: - return float(value) - elif value.lower() in ['true', 'false']: - return value.lower() == 'true' - else: - return value - - class ScenarioProperties: - name: str - index: int - identifier: str - user: Optional[str] - weight: float - _iterations: Optional[int] - _user_count: Optional[int] - - def __init__( - self, - name: str, - index: int, - weight: Optional[float] = None, - user: Optional[str] = None, - iterations: Optional[int] = None, - user_count: Optional[int] = None, - ) -> None: - self.name = name - self.index = index - self.user = user - self._iterations = iterations - self.weight = weight or 1.0 - self.identifier = f'{index:03}' - self._user_count = user_count - - @property - def iterations(self) -> int: - if self._iterations is None: # pragma: no cover - raise ValueError('iterations has not been set') - - return self._iterations - - @iterations.setter - def iterations(self, value: int) -> None: - self._iterations = value - - @property - def user_count(self) -> int: - if self._user_count is None: # pragma: no cover - raise ValueError('user count has not been set') - return self._user_count - - @user_count.setter - def user_count(self, value: int) -> None: - self._user_count = value - - def is_fulfilled(self) -> bool: - return self.user is not None and self._iterations is not None and self._user_count is not None - - distribution: Dict[str, ScenarioProperties] = {} + if check_value.isdecimal(): + if float(value) % 1 == 0: + if value.startswith('0'): + return str(value) + + return int(float(value)) + + return float(value) + + if value.lower() in ['true', 'false']: + return value.lower() == 'true' + + return value + + +class ScenarioProperties: + name: str + index: int + identifier: str + user: Optional[str] + weight: float + _iterations: Optional[int] + _user_count: Optional[int] + + def __init__( + self, + name: str, + index: int, + weight: Optional[float] = None, + user: Optional[str] = None, + iterations: Optional[int] = None, + user_count: Optional[int] = None, + ) -> None: + self.name = name + self.index = index + self.user = user + self._iterations = iterations + self.weight = weight or 1.0 + self.identifier = f'{index:03}' + self._user_count = user_count + + @property + def iterations(self) -> int: + if self._iterations is None: # pragma: no cover + message = 'iterations has not been set' + raise ValueError(message) + + return self._iterations + + @iterations.setter + def iterations(self, value: int) -> None: + self._iterations = value + + @property + def user_count(self) -> int: + if self._user_count is None: # pragma: no cover + message = 'user count has not been set' + raise ValueError(message) + return self._user_count + + @user_count.setter + def user_count(self, value: int) -> None: + self._user_count = value + + def is_fulfilled(self) -> bool: + return self.user is not None and self._iterations is not None and self._user_count is not None + + +def distribution_of_users_per_scenario(args: Arguments, environ: dict) -> None: # noqa: C901, PLR0912, PLR0915 + distribution: dict[str, ScenarioProperties] = {} variables = {key.replace('TESTDATA_VARIABLE_', ''): _guess_datatype(value) for key, value in environ.items() if key.startswith('TESTDATA_VARIABLE_')} def _pre_populate_scenario(scenario: Scenario, index: int) -> None: @@ -738,9 +736,10 @@ def _pre_populate_scenario(scenario: Scenario, index: int) -> None: use_weights = True for index, scenario in enumerate(grizzly_cli.SCENARIOS): - scenario_variables: Dict[str, Any] = {} + scenario_variables: dict = {} if len(scenario.steps) < 1: - raise ValueError(f'scenario "{scenario.name}" does not have any steps') + message = f'scenario "{scenario.name}" does not have any steps' + raise ValueError(message) _pre_populate_scenario(scenario, index=index + 1) @@ -763,7 +762,7 @@ def _pre_populate_scenario(scenario: Scenario, index: int) -> None: variable_name = match.group(1) variable_value = Template(match.group(2)).render(**variables, **scenario_variables) scenario_variables.update({variable_name: variable_value}) - except: + except: # noqa: S112 continue elif step.name.startswith('a user of type'): match = re.match(r'a user of type "([^"]*)" (with weight "([^"]*)")?.*', step.name) @@ -774,8 +773,7 @@ def _pre_populate_scenario(scenario: Scenario, index: int) -> None: match = re.match(r'repeat for "([^"]*)" iteration[s]?', step.name) if match: distribution[scenario.name].iterations = int(round(float(Template(match.group(1)).render(**variables, **scenario_variables)), 0)) - (distribution[scenario.name].iterations) - elif any([pattern in step.name for pattern in ['users of type', 'user of type']]): + elif any(pattern in step.name for pattern in ['users of type', 'user of type']): match = re.match(r'"([^"]*)" user[s]? of type "([^"]*)".*', step.name) if match: scenario_user_count = int(round(float(Template(match.group(1)).render(**variables, **scenario_variables)), 0)) @@ -790,13 +788,15 @@ def _pre_populate_scenario(scenario: Scenario, index: int) -> None: scenario_count = len(distribution.keys()) assert scenario_user_count_total is not None if scenario_count > scenario_user_count_total: - raise ValueError(f'grizzly needs at least {scenario_count} users to run this feature') + message = f'grizzly needs at least {scenario_count} users to run this feature' + raise ValueError(message) total_weight = 0 total_iterations = 0 for scenario in distribution.values(): if scenario.user is None: - raise ValueError(f'{scenario.name} does not have a user type') + message = f'{scenario.name} does not have a user type' + raise ValueError(message) total_weight += scenario.weight total_iterations += scenario.iterations @@ -830,7 +830,7 @@ def print_table_lines(max_length_iterations: int, max_length_users: int, max_len line += ['-' * (max_length_errors + 1), '-|'] logger.info(''.join(line)) - rows: List[str] = [] + rows: list[str] = [] max_length_description = len('description') max_length_iterations = len('#iter') max_length_users = len('#user') @@ -840,9 +840,9 @@ def print_table_lines(max_length_iterations: int, max_length_users: int, max_len if hasattr(args, 'environment_file') and args.environment_file is not None: message = f'{message} with environment file {environ["GRIZZLY_CONFIGURATION_FILE"]}' - logger.info(f'{message}\n') + logger.info('%s\n', message) - errors: Dict[str, List[str]] = {} + errors: dict[str, list[str]] = {} for scenario in distribution.values(): # check for errors @@ -876,7 +876,7 @@ def print_table_lines(max_length_iterations: int, max_length_users: int, max_len row_format.append(' {}') for scenario in distribution.values(): - row_format_args: List[Any] = [ + row_format_args: list = [ scenario.identifier, scenario.weight, scenario.iterations, @@ -899,7 +899,7 @@ def print_table_lines(max_length_iterations: int, max_length_users: int, max_len rows.append(''.join(row_format).format(*row_format_args)) logger.info('each scenario will execute accordingly:\n') - header_row_args: List[Any] = [ + header_row_args: list = [ 'ident', 'weight', '#iter', max_length_iterations, @@ -926,19 +926,20 @@ def print_table_lines(max_length_iterations: int, max_length_users: int, max_len arrow_width = len('ident') + 2 + max_length_iterations + 2 + max_length_users + 2 + max_length_description + 2 if use_weights: arrow_width += len('weight') + 2 - message = f'''{" " * (1 + arrow_width)}^ + message = f"""{" " * (1 + arrow_width)}^ +{"-" * arrow_width}+ | +- there were errors when calculating user distribution and iterations per scenario, adjust user "weight", number of users or iterations per scenario -''' +""" raise ValueError(message) - else: - logger.info('') + + logger.info('') for scenario in distribution.values(): if scenario.iterations < scenario.user_count: - raise ValueError(f'{scenario.name} will have {scenario.user_count} users to run {scenario.iterations} iterations, increase iterations or lower user count') + message = f'{scenario.name} will have {scenario.user_count} users to run {scenario.iterations} iterations, increase iterations or lower user count' + raise ValueError(message) if not args.yes: ask_yes_no('continue?') @@ -985,7 +986,7 @@ def setup_logging(logfile: Optional[str] = None) -> None: logging.config.dictConfig(logging_config) -def unflatten(key: str, value: Any) -> dict[str, Any]: +def unflatten(key: str, value: Any) -> dict: paths: list[str] = key.split('.') # last node should have the value @@ -1001,9 +1002,9 @@ def unflatten(key: str, value: Any) -> dict[str, Any]: return struct -def flatten(node: dict[str, Any], parents: Optional[list[str]] = None) -> dict[str, Any]: +def flatten(node: dict, parents: Optional[list[str]] = None) -> dict: """Flatten a dictionary so each value key is the path down the nested dictionary structure.""" - flat: dict[str, Any] = {} + flat: dict = {} if parents is None: parents = [] @@ -1019,7 +1020,7 @@ def flatten(node: dict[str, Any], parents: Optional[list[str]] = None) -> dict[s return flat -def merge_dicts(merged: dict[str, Any], source: dict[str, Any]) -> dict[str, Any]: +def merge_dicts(merged: dict, source: dict) -> dict: """Merge two dicts recursively, where `source` values takes precedance over `merged` values.""" merged = deepcopy(merged) source = deepcopy(source) @@ -1041,9 +1042,7 @@ def merge_dicts(merged: dict[str, Any], source: dict[str, Any]) -> dict[str, Any def chunker(value: str, size: int) -> list[str]: - return list( - map(lambda x: value[x * size:x * size + size], - list(range(ceil(len(value) / size))))) + return [value[x * size:x * size + size] for x in list(range(ceil(len(value) / size)))] def get_indentation(file: Path) -> int: @@ -1059,7 +1058,7 @@ class IndentDumper(Dumper): use_indent: ClassVar[int] @classmethod - def use_indentation(cls, target: Path | int) -> type['IndentDumper']: + def use_indentation(cls, target: Path | int) -> type[IndentDumper]: cls.use_indent = get_indentation(target) if isinstance(target, Path) else target return cls @@ -1069,5 +1068,5 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.best_indent = self.use_indent - def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: - return super().increase_indent(flow, False) + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: # noqa: ARG002, FBT001, FBT002 + return super().increase_indent(flow, False) # noqa: FBT003 diff --git a/grizzly_cli/utils/configuration.py b/grizzly_cli/utils/configuration.py index 2fb35eb..f624ee5 100644 --- a/grizzly_cli/utils/configuration.py +++ b/grizzly_cli/utils/configuration.py @@ -1,28 +1,32 @@ from __future__ import annotations import re -from pathlib import Path -from contextlib import suppress -from textwrap import dedent -from typing import Any, Iterable, ClassVar, cast from base64 import b64decode -from cryptography.hazmat.primitives.serialization import pkcs12 -from cryptography.hazmat.primitives._serialization import PBES, KeySerializationEncryptionBuilder, PrivateFormat, KeySerializationEncryption -from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes -from cryptography.x509 import Certificate -from cryptography.hazmat.primitives import serialization +from contextlib import suppress +from pathlib import Path from shutil import which +from textwrap import dedent +from typing import TYPE_CHECKING, ClassVar, Optional, cast import yaml -from azure.identity import AzureCliCredential, ManagedIdentityCredential, ChainedTokenCredential -from azure.keyvault.secrets import SecretClient, KeyVaultSecret +from azure.identity import AzureCliCredential, ChainedTokenCredential, ManagedIdentityCredential +from azure.keyvault.secrets import KeyVaultSecret, SecretClient +from behave.parser import parse_feature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives._serialization import PBES, KeySerializationEncryption, KeySerializationEncryptionBuilder, PrivateFormat +from cryptography.hazmat.primitives.serialization import pkcs12 from jinja2 import Environment from jinja2.lexer import Token, TokenStream from jinja2_simple_tags import StandaloneTag -from behave.parser import parse_feature -from behave.model import Scenario -from grizzly_cli.utils import IndentDumper, merge_dicts, logger, unflatten, run_command +from grizzly_cli.utils import IndentDumper, logger, merge_dicts, run_command, unflatten + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Iterable + + from behave.model import Scenario + from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes + from cryptography.x509 import Certificate def get_context_root() -> Path: @@ -39,20 +43,21 @@ def get_context_root() -> Path: context_root = possible_context_root if context_root is None: - raise ValueError('context root not found, are you in a grizzly project?') + message = 'context root not found, are you in a grizzly project?' + raise ValueError(message) return context_root.parent class ScenarioTag(StandaloneTag): - tags = {'scenario'} + tags: ClassVar[set[str]] = {'scenario'} def preprocess( - self, source: str, name: str | None, filename: str | None = None + self, source: str, name: str | None, filename: str | None = None, ) -> str: self._source = source - return cast(str, super().preprocess(source, name, filename)) + return cast('str', super().preprocess(source, name, filename)) @classmethod def get_scenario_text(cls, name: str, file: Path) -> str: @@ -64,11 +69,13 @@ def get_scenario_text(cls, name: str, file: Path) -> str: assert len(content.splitlines()) == len(content_skel.splitlines()), 'oops, there is not a 1:1 match between lines!' feature = parse_feature(content_skel, filename=file.as_posix()) - scenarios = cast(list[Scenario], feature.scenarios) + scenarios = cast('list[Scenario]', feature.scenarios) lines = content.splitlines() - for scenario_index, scenario in enumerate(scenarios): + scenario_index: int + for index, scenario in enumerate(scenarios): if scenario.name == name: + scenario_index = index break # check if there are scenarios after our scenario in the source @@ -155,7 +162,7 @@ def render(self, scenario: str, feature: str, **variables: str) -> str: return scenario_content - def filter_stream(self, stream: TokenStream) -> TokenStream | Iterable[Token]: # type: ignore[return] + def filter_stream(self, stream: TokenStream) -> TokenStream | Iterable[Token]: # type: ignore[return] # noqa: PLR0912 """Everything outside of `{% scenario ... %}` (and `{% if ... %}...{% endif %}`) should be treated as "data", e.g. plain text. Overloaded from `StandaloneTag`, must match method signature, which is not `Generator`, even though we yield @@ -230,7 +237,7 @@ def preprocess( self, source: str, name: str | None, filename: str | None = None, ) -> str: self._source = source - return cast(str, super().preprocess(source, name, filename)) + return cast('str', super().preprocess(source, name, filename)) def render(self, filename: str, *filenames: str) -> str: buffer: list[str] = [] @@ -388,7 +395,8 @@ def _write_mqm_cert( runmqakm_path = which('runmqakm') if runmqakm_path is None: - raise ValueError('runmqakm could not be found, install IBM MQC Redist, and make sure that it\'s bin/ directory is added to PATH') + message = "runmqakm could not be found, install IBM MQC Redist, and make sure that it's bin/ directory is added to PATH" + raise ValueError(message) runmqakm_cmd: list[str] = [ runmqakm_path, @@ -415,7 +423,8 @@ def _write_mqm_cert( for line in result.output or []: logger.error(line.decode('utf-8').strip()) - raise ValueError(f'failed to create {relative_file}') + message = f'failed to create {relative_file}' + raise ValueError(message) finally: p12_file.unlink() cms_file.with_suffix('.crl').unlink(missing_ok=True) @@ -446,7 +455,7 @@ def _write_pem_public(root: Path, name: str, public_certificate: Certificate, ad certificate_data: list[bytes] = [] - for certificate in [public_certificate] + additional_certificates: + for certificate in [public_certificate, *additional_certificates]: certificate_pem = certificate.public_bytes(encoding=serialization.Encoding.PEM) certificate_data.append(certificate_pem) @@ -459,7 +468,8 @@ def _write_file(root: Path, content_type: str, encoded_content: str) -> str: file_name = _get_metadata(content_type, 'file') if file_name is None: - raise ValueError('could not find `file:` in content type') + message = 'could not find `file:` in content type' + raise ValueError(message) file = _create_safe_file_and_parent(root / 'files' / file_name) @@ -530,9 +540,7 @@ def _import_files(client: SecretClient, root: Path, secret: KeyVaultSecret) -> s return conf_value -def load_configuration(configuration_file: str) -> str: - file = Path(configuration_file) - +def load_configuration(file: Path) -> Path: if not file.exists(): message = f'{file.as_posix()} does not exist' raise ValueError(message) @@ -562,12 +570,12 @@ def load_configuration(configuration_file: str) -> str: with environment_lock_file.open('w') as fd: yaml.dump(configuration, fd, Dumper=IndentDumper.use_indentation(file), default_flow_style=False, sort_keys=False, allow_unicode=True) - return configuration_file.replace(file.name, f'{file.stem}.lock{file.suffix}') + return file.with_name(f'{file.stem}.lock{file.suffix}') -def load_configuration_file(file: Path) -> dict[str, Any]: +def load_configuration_file(file: Path) -> dict: """Load a grizzly environment file and flatten the structure.""" - configuration: dict[str, Any] = {} + configuration: dict = {} environment = Environment(autoescape=False, extensions=[MergeYamlTag]) environment.extend(source_file=file) @@ -586,13 +594,9 @@ def load_configuration_file(file: Path) -> dict[str, Any]: return configuration -def load_configuration_keyvault(client: SecretClient, environment: str, root: Path, *, filter_keys: list[str] | None) -> tuple[dict[str, Any], int]: - environment_filter = ['global', environment] - +def filter_secrets(client: SecretClient, environment_filter: list[str]) -> dict[str, str]: secret_properties = client.list_properties_of_secrets() - keys: dict[str, str] = {} - configuration: dict[str, Any] = {} # loop through all secrets to find the ones that match the environment filter for secret_property in secret_properties: @@ -617,6 +621,107 @@ def load_configuration_keyvault(client: SecretClient, environment: str, root: Pa keys.update({secret_property.name: name}) + return keys + +def get_certificate_encryption_algorithm(client: SecretClient, password_key: str | None) -> tuple[KeySerializationEncryption, Optional[str]]: + # build encryption algorithm + if password_key is not None: + password_secret = client.get_secret(password_key) + password = password_secret.value + else: + password = None + + if password is not None: + encryption_algorithm = KeySerializationEncryptionBuilder( + PrivateFormat.PKCS12, + _key_cert_algorithm=PBES.PBESv1SHA1And3KeyTripleDESCBC, + ).build(password.encode('utf-8')) + else: + encryption_algorithm = serialization.NoEncryption() + + return encryption_algorithm, password + + +def encode_certificate(client: SecretClient, root: Path, secret: KeyVaultSecret, content_type: str) -> str: + assert secret.value is not None + arguments: dict[str, str] = {} + + for part in secret.value.split(',', 1) + content_type.split(','): + argument, value = part.split(':', 1) + arguments.update({argument: value}) + + cert_key = arguments['cert'] + cert_secret = client.get_secret(cert_key) + + if cert_secret.value is None: + message = f'unable to download certificate secret {cert_key}' + raise ValueError(message) + + if arguments.get('name') is None: + name, _ = cert_key.split('-', 1) + arguments.update({'name': name.lower()}) + + certificate = b64decode(cert_secret.value) + + private_key, public_certificate, additional_certificates = pkcs12.load_key_and_certificates(data=certificate, password=None) + + encryption_algorithm, password = get_certificate_encryption_algorithm(client, arguments.get('pass')) + + # write files + cert_format = arguments.get('format') + if cert_format == 'pem-private': + if private_key is None: + message = f'could not find a private key in {cert_key}' + raise ValueError(message) + + conf_value = _write_pem_private(root, arguments['name'], encryption_algorithm, private_key) + elif cert_format == 'pem-public': + if public_certificate is None: + message = f'could not find a public certificate in {cert_key}' + raise ValueError(message) + + conf_value = _write_pem_public(root, arguments['name'], public_certificate, additional_certificates) + elif cert_format == 'mqm': + conf_value = _write_mqm_cert( + root, + arguments['name'], + password, + cast('pkcs12.PKCS12PrivateKeyTypes | None', private_key), + public_certificate, + additional_certificates, + encryption_algorithm, + ) + else: + message = f'{cert_format} is not a supported certificate format' + raise ValueError(message) + + return conf_value + + +def encode_secret_value(client: SecretClient, root: Path, secret: KeyVaultSecret, secret_key: str) -> str: + assert secret.value is not None + assert secret.properties.content_type is not None + + content_type = secret.properties.content_type + + if content_type.startswith('files'): + conf_value = _import_files(client, root, secret) + if all(keyword in secret_key for keyword in ['mq', 'key']): + conf_value = Path(conf_value).with_suffix('').as_posix() + elif content_type.startswith('file:'): + conf_value = _write_file(root, content_type, secret.value) + elif content_type.startswith('format:') and secret.value.startswith('cert:'): + conf_value = encode_certificate(client, root, secret, content_type) + else: + message = f'unknown content type for secret {secret_key}: {content_type}' + raise ValueError(message) + + return conf_value + + +def load_configuration_keyvault(client: SecretClient, environment: str, root: Path, *, filter_keys: list[str] | None) -> tuple[dict, int]: + keys = filter_secrets(client, environment_filter=['global', environment]) + configuration: dict = {} imported_secrets = 0 # get the actual value for all secrets that matched environment filter @@ -638,78 +743,7 @@ def load_configuration_keyvault(client: SecretClient, environment: str, root: Pa if content_type is not None: no_conf = 'noconf' in content_type - - if content_type.startswith('files'): - conf_value = _import_files(client, root, secret) - if all(keyword in secret_key for keyword in ['mq', 'key']): - conf_value = Path(conf_value).with_suffix('').as_posix() - elif content_type.startswith('file:'): - conf_value = _write_file(root, content_type, secret.value) - elif content_type.startswith('format:') and secret.value.startswith('cert:'): - arguments: dict[str, str] = {} - - for part in secret.value.split(',', 1) + content_type.split(','): - argument, value = part.split(':', 1) - arguments.update({argument: value}) - - cert_key = arguments['cert'] - cert_secret = client.get_secret(cert_key) - - if arguments.get('name') is None: - name, _ = cert_key.split('-', 1) - arguments.update({'name': name.lower()}) - if cert_secret.value is None: - message = f'unable to download certificate secret {cert_key}' - raise ValueError(message) - - certificate = b64decode(cert_secret.value) - - private_key, public_certificate, additional_certificates = pkcs12.load_key_and_certificates(data=certificate, password=None) - - # build encryption algorithm - password_key = arguments.get('pass') - if password_key is not None: - password_secret = client.get_secret(password_key) - password = password_secret.value - else: - password = None - - if password is not None: - encryption_algorithm = KeySerializationEncryptionBuilder( - PrivateFormat.PKCS12, - _key_cert_algorithm=PBES.PBESv1SHA1And3KeyTripleDESCBC, - ).build(password.encode('utf-8')) - else: - encryption_algorithm = serialization.NoEncryption() - - # write files - cert_format = arguments.get('format') - if cert_format == 'pem-private': - if private_key is None: - raise ValueError(f'could not find a private key in {cert_key}') - - conf_value = _write_pem_private(root, arguments['name'], encryption_algorithm, private_key) - elif cert_format == 'pem-public': - if public_certificate is None: - raise ValueError(f'could not find a public certificate in {cert_key}') - - conf_value = _write_pem_public(root, arguments['name'], public_certificate, additional_certificates) - elif cert_format == 'mqm': - conf_value = _write_mqm_cert( - root, - arguments['name'], - password, - cast(pkcs12.PKCS12PrivateKeyTypes | None, private_key), - public_certificate, - additional_certificates, - encryption_algorithm, - ) - else: - message = f'{cert_format} is not a supported certificate format' - raise ValueError(message) - else: - message = f'unknown content type for secret {secret_key}: {content_type}' - raise ValueError(message) + conf_value = encode_secret_value(client, root, secret, secret_key) if not no_conf: logger.debug('mapping %s to %s', conf_key, conf_value if content_type is not None else '******') diff --git a/pyproject.toml b/pyproject.toml index 60317b0..40dd095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,14 +61,12 @@ grizzly-cli = "grizzly_cli.__main__:main" [project.optional-dependencies] dev = [ "mypy ==1.11.1", - "pylint ==3.2.6", "pytest ==8.3.2", "coverage[toml] ==7.6.1", "pytest-cov ==5.0.0", "pytest-mock ==3.14.0", "pytest-timeout ==2.3.1", "atomicwrites ==1.4.1", - "flake8-pyproject ==1.2.3", "requests-mock ==1.12.1", "pip-licenses ==4.3.4", "pytablewriter ==1.2.0", @@ -79,7 +77,8 @@ dev = [ "types-requests >=2.27.13,<3.0.0", "setuptools-scm==8.1.0", "types-pyyaml", - "snakeviz" + "snakeviz", + "ruff ==0.12.1" ] ci = [ "build ==1.1.1", @@ -102,54 +101,6 @@ grizzly_cli = [ "argparse/bashcompletion/bashcompletion.bash" ] -[tool.pylint.master] -ignore = [".env", ".venv", ".pytest_tmp", "tests/e2e/example"] -jobs = 1 -disable = "all" -enable = [ - 'F', - 'unreachable', - 'duplicate-key', - 'unnecessary-semicolon', - 'global-variable-not-assigned', - 'unused-variable', - 'unused-wildcard-import', - 'unused-import', - 'line-too-long', - 'binary-op-exception', - 'bad-format-string', - 'anomalous-backslash-in-string', - 'bad-open-mode', - 'E0001', 'E0011', 'E0012', 'E0100', 'E0101', - 'E0102', 'E0103', 'E0104', 'E0105', 'E0107', - 'E0108', 'E0110', 'E0111', 'E0112', 'E0113', - 'E0114', 'E0115', 'E0116', 'E0117', 'E0118', - 'E0202', 'E0203', 'E0211', 'E0213', 'E0236', - 'E0237', 'E0238', 'E0239', 'E0240', 'E0241', - 'E0301', 'E0302', 'E0303', 'E0401', 'E0402', - 'E0601', 'E0602', 'E0603', 'E0604', 'E0611', - 'E0632', 'E0633', 'E0701', 'E0702', 'E0703', - 'E0704', 'E0710', 'E0711', 'E0712', 'E1003', - 'E1101', 'E1102', 'E1111', 'E1120', 'E1121', - 'E1123', 'E1124', 'E1125', 'E1126', 'E1127', - 'E1128', 'E1129', 'E1130', 'E1131', 'E1132', - 'E1133', 'E1134', 'E1135', 'E1136', 'E1137', - 'E1138', 'E1139', 'E1200', 'E1201', 'E1205', - 'E1206', 'E1300', 'E1301', 'E1302', 'E1303', - 'E1304', 'E1305', 'E1306', 'E1310', 'E1700', - 'E1701' -] -max-line-length = 180 -msg-template = "{path}:{line}:{column},{category},{symbol}:{msg}" -reports = "no" -output-format = "text" -generated-members = [] - -[tool.flake8] -max-line-length = 180 -ignore = ["E722", "W503", "E402", "F405", "F403"] -exclude = [".git", "__pycache__", "docs", "build", "dist", ".pytest_tmp"] - [tool.mypy] exclude = ["tests/e2e/example"] # https://github.com/python/mypy/issues/5870 @@ -202,3 +153,42 @@ addopts = [ "--cov-report=", "--no-cov-on-fail" ] + +[tool.ruff] +exclude = [ + ".env", ".venv", ".pytest_tmp", "build", "dist", "grizzly_cli/__version__.py" +] +line-length = 180 +indent-width = 4 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN401", + "BLE001", + "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D203", "D213", "D205", "D301", "D407", "D417", + "DTZ005", + "E722", "E402", + "F405", "F403", + "N801", + "PD011", + "PLR2004", + "UP007", "UP006", "UP045", + "Q000", + "S101", "S104", "S314", "S603", "S607", "S701", + "SLF001", + "T201", + "TRY301" +] + +[tool.ruff.lint.per-file-ignores] +"script/**" = ["INP001", "D100"] +"tests/unit/utils/test___init__.py" = ["W291"] +"tests/e2e/example/**" = ["ALL"] + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint.pylint] +max-branches = 15 +max-args = 10 diff --git a/tests/conftest.py b/tests/conftest.py index ddd51cf..7c6de26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,22 @@ -import sys +from __future__ import annotations -from typing import Generator, List +import sys from os import environ +from typing import TYPE_CHECKING import pytest -from _pytest.tmpdir import TempPathFactory -from _pytest.config import Config -from _pytest.fixtures import SubRequest - -from .fixtures import ( +from tests.fixtures import ( End2EndFixture, ) +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Generator + + from _pytest.config import Config + from _pytest.fixtures import SubRequest + from _pytest.tmpdir import TempPathFactory + E2E_RUN_MODE = environ.get('E2E_RUN_MODE', 'local') E2E_RUN_DIST = environ.get('E2E_RUN_DIST', 'False').lower() == 'True'.lower() @@ -31,7 +35,7 @@ def pytest_configure(config: Config) -> None: # also, add markers for each test function that starts with test_e2e_, if we're running everything -def pytest_collection_modifyitems(items: List[pytest.Function]) -> None: +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for item in items: if item.originalname.startswith('test_e2e_') and item.get_closest_marker('timeout') is None: item.add_marker(pytest.mark.timeout(PYTEST_TIMEOUT)) diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index 2e8e141..84baeb4 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -1,19 +1,23 @@ -import re +from __future__ import annotations +import re +from contextlib import contextmanager, suppress from os import environ -from typing import Generator, Optional, Tuple -from contextlib import contextmanager +from typing import TYPE_CHECKING, Optional import pytest -from _pytest.tmpdir import TempPathFactory +from tests.helpers import rm_rf, run_command + +if TYPE_CHECKING: + from collections.abc import Generator -from tests.helpers import run_command, rm_rf + from _pytest.tmpdir import TempPathFactory @contextmanager -def auth_via(tmp_path_factory: TempPathFactory, method: str) -> Generator[Tuple[Optional[str], Optional[str]], None, None]: - secret = 'asdfasdf' +def auth_via(tmp_path_factory: TempPathFactory, method: str) -> Generator[tuple[Optional[str], Optional[str]], None, None]: + secret = 'asdfasdf' # noqa: S105 test_context = tmp_path_factory.mktemp('test_context') argument: Optional[str] = None stdin: Optional[str] = None @@ -30,13 +34,11 @@ def auth_via(tmp_path_factory: TempPathFactory, method: str) -> Generator[Tuple[ argument = str(file) try: - yield (argument, stdin,) + yield (argument, stdin) finally: if method == 'env': - try: + with suppress(KeyError): del environ['OTP_SECRET'] - except: - pass rm_rf(test_context) diff --git a/tests/e2e/test_init.py b/tests/e2e/test_init.py index 17fd1b6..e93cac4 100644 --- a/tests/e2e/test_init.py +++ b/tests/e2e/test_init.py @@ -1,14 +1,18 @@ -from typing import List, Dict, Optional -from packaging.version import Version +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional import pytest +from packaging.version import Version -from _pytest.tmpdir import TempPathFactory +from tests.helpers import rm_rf, run_command -from tests.helpers import run_command, rm_rf +if TYPE_CHECKING: + from _pytest.tmpdir import TempPathFactory -@pytest.mark.parametrize('arguments,expected', [ +@pytest.mark.parametrize( + ('arguments', 'expected'), [ ( [], {'mq_output': 'without IBM MQ support', 'grizzly_output': 'latest grizzly version', 'grizzly_requirements': 'grizzly-loadtester'}, @@ -26,7 +30,7 @@ {'mq_output': 'with IBM MQ support', 'grizzly_output': 'pinned to grizzly version 1.1.1', 'grizzly_requirements': 'grizzly-loadtester[mq]==1.1.1'}, ), ]) -def test_e2e_init(arguments: List[str], expected: Dict[str, str], tmp_path_factory: TempPathFactory) -> None: +def test_e2e_init(arguments: list[str], expected: dict[str, str], tmp_path_factory: TempPathFactory) -> None: test_context = tmp_path_factory.mktemp('test_context') grizzly_version: Optional[Version] = None @@ -36,22 +40,19 @@ def test_e2e_init(arguments: List[str], expected: Dict[str, str], tmp_path_facto except ValueError: grizzly_version = None - if grizzly_version is None or grizzly_version >= Version('2.6.0'): - grizzly_behave_module = 'behave' - else: - grizzly_behave_module = 'environment' + grizzly_behave_module = 'behave' if grizzly_version is None or grizzly_version >= Version('2.6.0') else 'environment' try: rc, output = run_command( - ['grizzly-cli', 'init', 'foobar', '--yes'] + arguments, - cwd=str(test_context), + ['grizzly-cli', 'init', 'foobar', '--yes', *arguments], + cwd=test_context, ) try: assert rc == 0 except AssertionError: print(''.join(output)) raise - assert ''.join(output) == f'''the following structure will be created: + assert ''.join(output) == f"""the following structure will be created: foobar ├── environments @@ -67,7 +68,7 @@ def test_e2e_init(arguments: List[str], expected: Dict[str, str], tmp_path_facto successfully created project "foobar", with the following options: • {expected["mq_output"]} • {expected["grizzly_output"]} -''' +""" assert (test_context / 'foobar').is_dir() assert (test_context / 'foobar' / 'environments').is_dir() @@ -77,10 +78,10 @@ def test_e2e_init(arguments: List[str], expected: Dict[str, str], tmp_path_facto environments_file = test_context / 'foobar' / 'environments' / 'foobar.yaml' assert environments_file.is_file() - assert environments_file.read_text() == '''configuration: + assert environments_file.read_text() == """configuration: template: host: https://localhost -''' +""" features_dir = test_context / 'foobar' / 'features' assert features_dir.is_dir() @@ -99,9 +100,9 @@ def test_e2e_init(arguments: List[str], expected: Dict[str, str], tmp_path_facto feature_file = features_dir / 'foobar.feature' assert feature_file.is_file() - assert feature_file.read_text() == '''Feature: Template feature file + assert feature_file.read_text() == """Feature: Template feature file Scenario: Template scenario Given a user of type "RestApi" with weight "1" load testing "$conf::template.host" -''' +""" finally: rm_rf(test_context) diff --git a/tests/e2e/test_run.py b/tests/e2e/test_run.py index f6d0ffd..8a6ce5e 100644 --- a/tests/e2e/test_run.py +++ b/tests/e2e/test_run.py @@ -1,116 +1,163 @@ -import sys +from __future__ import annotations +import sys +from os import pathsep +from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Optional -from os import path, pathsep +from typing import TYPE_CHECKING, Optional import pytest import yaml -from tests.fixtures import End2EndFixture -from tests.helpers import run_command, rm_rf +from tests.helpers import rm_rf, run_command +if TYPE_CHECKING: + from tests.fixtures import End2EndFixture -def test_e2e_run_example(e2e_fixture: End2EndFixture) -> None: - if sys.version_info < (3, 8,) and not e2e_fixture._distributed: - pytest.skip('grizzly-loadtester only supports python >= 3.8') - if sys.platform == 'win32' and e2e_fixture._distributed: - pytest.skip('windows github runners do not support running linux containers') +def prepare_example_project(e2e_fixture: End2EndFixture) -> Path: + example_root = e2e_fixture.root / 'grizzly-example' - result: Optional[str] = None + example_root.mkdir() - try: - example_root = e2e_fixture.root / 'grizzly-example' + rc, _ = run_command([ + 'git', 'init', + ], cwd=example_root) + + assert rc == 0 + + rc, _ = run_command([ + 'git', 'remote', 'add', '-f', 'origin', 'https://github.com/Biometria-se/grizzly.git', + ], cwd=example_root) + + assert rc == 0 + + rc, _ = run_command([ + 'git', 'sparse-checkout', 'init', + ], cwd=example_root) - example_root.mkdir() + assert rc == 0 - rc, _ = run_command([ - 'git', 'init', - ], cwd=str(example_root)) + rc, _ = run_command([ + 'git', 'sparse-checkout', 'set', 'example', + ], cwd=example_root) - assert rc == 0 + assert rc == 0 - rc, _ = run_command([ - 'git', 'remote', 'add', '-f', 'origin', 'https://github.com/Biometria-se/grizzly.git' - ], cwd=str(example_root)) + rc, _ = run_command([ + 'git', 'pull', 'origin', 'main', + ], cwd=example_root) - assert rc == 0 + assert rc == 0 - rc, _ = run_command([ - 'git', 'sparse-checkout', 'init', - ], cwd=str(example_root)) + rm_rf(example_root / '.git') - assert rc == 0 + return example_root / 'example' - rc, _ = run_command([ - 'git', 'sparse-checkout', 'set', 'example', - ], cwd=str(example_root)) - assert rc == 0 +def validate_result(rc: int, result: str, example_root: Path) -> None: + assert rc == 0 + assert 'ERROR' not in result + assert 'WARNING' not in result + assert '1 feature passed, 0 failed, 0 skipped' in result + assert '3 scenarios passed, 0 failed, 0 skipped' in result + assert 'steps passed, 0 failed, 0 skipped, 0 undefined' in result - rc, _ = run_command([ - 'git', 'pull', 'origin', 'main', - ], cwd=str(example_root)) + assert 'ident iter status description' in result + assert '001 2/2 passed dog facts api' in result + assert '002 1/1 passed cat facts api' in result + assert '003 1/1 passed book api' in result + assert '------|-----|--------|---------------|' in result - assert rc == 0 + assert 'executing custom.User.request for 002 get-cat-facts and /facts?limit=' in result - rm_rf(example_root / '.git') + assert 'sending "client_server" from CLIENT' in result + assert "received from CLIENT" in result + assert "AtomicCustomVariable.foobar='foobar'" in result - example_root = example_root / 'example' + assert 'compose.yaml: `version` is obsolete' not in result - with open(example_root / 'features' / 'steps' / 'steps.py', 'a') as fd: + log_file_result = (example_root / 'test_run.log').read_text() + + # problems with a locust DEBUG log message containing ERROR in the message on macos-latest + if sys.platform == 'darwin': + output = [line for line in log_file_result.split('\n') if 'ERROR' not in line and 'DEBUG' not in line] + log_file_result = '\n'.join(output) + + if sys.version_info >= (3, 12): + result = result.replace('\r', '\n') + + assert log_file_result == result + + +def install_dependencies(e2e_fixture: End2EndFixture, example_root: Path) -> None: + if e2e_fixture._distributed: + command = ['grizzly-cli', 'dist', '--project-name', e2e_fixture.root.name, 'build', '--no-cache'] + rc, output = run_command( + command, + cwd=example_root, + env=e2e_fixture._env, + ) + try: + assert rc == 0 + except AssertionError: + print(''.join(output)) + raise + else: + command = ['python', '-m', 'pip', 'install', '--no-cache-dir', '-r', 'requirements.txt'] + if sys.platform == 'win32': + command += ['--user'] + + rc, output = run_command( + command, + cwd=example_root, + env=e2e_fixture._env, + ) + + try: + assert rc == 0 + except AssertionError: + print(''.join(output)) + raise + + +def test_e2e_run_example(e2e_fixture: End2EndFixture) -> None: + if sys.version_info < (3, 8) and not e2e_fixture._distributed: + pytest.skip('grizzly-loadtester only supports python >= 3.8') + + if sys.platform == 'win32' and e2e_fixture._distributed: + pytest.skip('windows github runners do not support running linux containers') + + result: Optional[str] = None + + try: + example_root = prepare_example_project(e2e_fixture) + + with (example_root / 'features' / 'steps' / 'steps.py').open('a') as fd: fd.write(e2e_fixture.start_webserver_step_impl(e2e_fixture.webserver_port)) e2e_fixture.inject_webserver_module(example_root) - with open(example_root / 'environments' / 'example.yaml') as env_yaml_file: + with (example_root / 'environments' / 'example.yaml').open('r') as env_yaml_file: env_conf = yaml.full_load(env_yaml_file) for name in ['dog', 'cat', 'book']: env_conf['configuration']['facts'][name]['host'] = f'http://{e2e_fixture.host}' - feature_file = path.join('features', 'example.feature') + feature_file = Path.joinpath(Path('features'), 'example.feature') feature_file_path = example_root / 'features' / 'example.feature' feature_file_contents = feature_file_path.read_text().split('\n') requirements_file = example_root / 'requirements.txt' requirements_file.write_text('grizzly-loadtester @ git+https://github.com/Biometria-se/grizzly.git@main\n') - if e2e_fixture._distributed: - command = ['grizzly-cli', 'dist', '--project-name', e2e_fixture.root.name, 'build', '--no-cache'] - rc, output = run_command( - command, - cwd=str(example_root), - env=e2e_fixture._env, - ) - try: - assert rc == 0 - except AssertionError: - print(''.join(output)) - raise - else: - command = ['python', '-m', 'pip', 'install', '--no-cache-dir', '-r', 'requirements.txt'] - if sys.platform == 'win32': - command += ['--user'] - - rc, output = run_command( - command, - cwd=str(example_root), - env=e2e_fixture._env, - ) - - try: - assert rc == 0 - except AssertionError: - print(''.join(output)) - raise + install_dependencies(e2e_fixture, example_root) index = feature_file_contents.index(' Scenario: dog facts api') # should go last in "Background"-section feature_file_contents.insert(index - 1, f' Then start webserver on master port "{e2e_fixture.webserver_port}"') - with open(feature_file_path, 'w') as fd: + with feature_file_path.open('w') as fd: fd.truncate(0) fd.write('\n'.join(feature_file_contents)) @@ -120,8 +167,8 @@ def test_e2e_run_example(e2e_fixture: End2EndFixture) -> None: rc, output = e2e_fixture.execute( feature_file, - env_conf_file.name.replace(f'{str(example_root)}{pathsep}', ''), - cwd=str(example_root), + env_conf_file.name.replace(f'{example_root.as_posix()}{pathsep}', ''), + cwd=example_root, arguments=['-l', 'test_run.log'], ) @@ -131,38 +178,7 @@ def test_e2e_run_example(e2e_fixture: End2EndFixture) -> None: result = ''.join(output) - assert rc == 0 - assert 'ERROR' not in result - assert 'WARNING' not in result - assert '1 feature passed, 0 failed, 0 skipped' in result - assert '3 scenarios passed, 0 failed, 0 skipped' in result - assert 'steps passed, 0 failed, 0 skipped, 0 undefined' in result - - assert 'ident iter status description' in result - assert '001 2/2 passed dog facts api' in result - assert '002 1/1 passed cat facts api' in result - assert '003 1/1 passed book api' in result - assert '------|-----|--------|---------------|' in result - - assert 'executing custom.User.request for 002 get-cat-facts and /facts?limit=' in result - - assert 'sending "client_server" from CLIENT' in result - assert "received from CLIENT" in result - assert "AtomicCustomVariable.foobar='foobar'" in result - - assert 'compose.yaml: `version` is obsolete' not in result - - log_file_result = (example_root / 'test_run.log').read_text() - - # problems with a locust DEBUG log message containing ERROR in the message on macos-latest - if sys.platform == 'darwin': - output = [line for line in log_file_result.split('\n') if 'ERROR' not in line and 'DEBUG' not in line] - log_file_result = '\n'.join(output) - - if sys.version_info >= (3, 12): - result = result.replace('\r', '\n') - - assert log_file_result == result + validate_result(rc, result, example_root) except: if result is not None: print(result) diff --git a/tests/e2e/test_version.py b/tests/e2e/test_version.py index 603e362..8a3e58a 100644 --- a/tests/e2e/test_version.py +++ b/tests/e2e/test_version.py @@ -1,25 +1,27 @@ -from typing import Optional +from __future__ import annotations -import pytest +from typing import TYPE_CHECKING, Optional -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture +import pytest -from tests.helpers import run_command, get_current_version, rm_rf +from tests.helpers import get_current_version, rm_rf, run_command +if TYPE_CHECKING: + from _pytest.tmpdir import TempPathFactory CURRENT_VERSION = get_current_version() -@pytest.mark.parametrize('pip_module,grizzly_version,locust_version', [ - ('grizzly-loadtester==1.0.0', '1.0.0', '2.2.1',), - ('grizzly-loadtester[mq]==2.4.6', '2.4.6 ── extras: mq', '2.9.0',), - ('git+https://git@github.com/biometria-se/grizzly.git@v1.4.1#egg=grizzly-loadtester', '(development)', '2.2.1',), - ('git+https://git@github.com/biometria-se/grizzly.git@v2.4.6#egg=grizzly-loadtester', '(development)', '2.9.0',), - ('git+https://git@github.com/biometria-se/grizzly.git@7285294b#egg=grizzly-loadtester', '2.4.7.dev7', '>=2.12.0,<2.13',), - ('grizzly-loadtester[mq] @ git+https://git@github.com/biometria-se/grizzly.git@7285294b', '2.4.7.dev7 ── extras: mq', '>=2.12.0,<2.13',), +@pytest.mark.parametrize( + ('pip_module', 'grizzly_version', 'locust_version'), [ + ('grizzly-loadtester==1.0.0', '1.0.0', '2.2.1'), + ('grizzly-loadtester[mq]==2.4.6', '2.4.6 ── extras: mq', '2.9.0'), + ('git+https://git@github.com/biometria-se/grizzly.git@v1.4.1#egg=grizzly-loadtester', '(development)', '2.2.1'), + ('git+https://git@github.com/biometria-se/grizzly.git@v2.4.6#egg=grizzly-loadtester', '(development)', '2.9.0'), + ('git+https://git@github.com/biometria-se/grizzly.git@7285294b#egg=grizzly-loadtester', '2.4.7.dev7', '>=2.12.0,<2.13'), + ('grizzly-loadtester[mq] @ git+https://git@github.com/biometria-se/grizzly.git@7285294b', '2.4.7.dev7 ── extras: mq', '>=2.12.0,<2.13'), ]) -def test_e2e_version(pip_module: str, grizzly_version: str, locust_version: str, tmp_path_factory: TempPathFactory, mocker: MockerFixture) -> None: +def test_e2e_version(pip_module: str, grizzly_version: str, locust_version: str, tmp_path_factory: TempPathFactory) -> None: test_context = tmp_path_factory.mktemp('test_context') result: Optional[str] = None @@ -28,7 +30,7 @@ def test_e2e_version(pip_module: str, grizzly_version: str, locust_version: str, # create project rc, output = run_command( ['grizzly-cli', 'init', 'foobar', '--yes'], - cwd=str(test_context) + cwd=test_context, ) try: @@ -44,16 +46,16 @@ def test_e2e_version(pip_module: str, grizzly_version: str, locust_version: str, rc, output = run_command( ['grizzly-cli', '--version', 'all'], - cwd=str(test_context / 'foobar') + cwd=(test_context / 'foobar'), ) result = ''.join(output) assert rc == 0 - assert f'''grizzly-cli {CURRENT_VERSION} + assert f"""grizzly-cli {CURRENT_VERSION} └── grizzly {grizzly_version} └── locust {locust_version} -''' in ''.join(output) +""" in ''.join(output) except AssertionError: if result is not None: print(result) diff --git a/tests/fixtures.py b/tests/fixtures.py index a4f1f8c..d8132d5 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,26 +1,29 @@ +from __future__ import annotations + import inspect import re import socket import sys - -from typing import Optional, Callable, Any, List, Tuple, Type, Dict, cast -from typing_extensions import Literal -from types import TracebackType -from os import environ, getcwd, pathsep, linesep +from contextlib import closing, suppress +from cProfile import Profile +from getpass import getuser +from hashlib import sha1 +from os import environ, linesep from pathlib import Path from textwrap import dedent, indent -from hashlib import sha1 -from getpass import getuser -from contextlib import closing -from cProfile import Profile +from typing import TYPE_CHECKING, Any, Callable, Optional, cast + +from typing_extensions import Literal, Self -from _pytest.tmpdir import TempPathFactory -from behave.runner import Context -from behave.model import Feature from grizzly_cli.utils import rm_rf +from tests.helpers import run_command -from .helpers import run_command +if TYPE_CHECKING: + from types import TracebackType + from _pytest.tmpdir import TempPathFactory + from behave.model import Feature + from behave.runner import Context __all__ = [ 'End2EndFixture', @@ -33,13 +36,13 @@ class End2EndValidator: name: str implementation: Any - table: Optional[List[Dict[str, str]]] + table: Optional[list[dict[str, str]]] def __init__( self, name: str, implementation: Callable[[Context], None], - table: Optional[List[Dict[str, str]]] = None, + table: Optional[list[dict[str, str]]] = None, ) -> None: self.name = name self.implementation = implementation @@ -47,12 +50,10 @@ def __init__( @property def expression(self) -> str: - lines: List[str] = [f'Then run validator {self.name}_{self.implementation.__name__}'] + lines: list[str] = [f'Then run validator {self.name}_{self.implementation.__name__}'] if self.table is not None and len(self.table) > 0: - lines.append(f' | {" | ".join([key for key in self.table[0].keys()])} |') - - for row in self.table: - lines.append(f' | {" | ".join([value for value in row.values()])} |') + lines.append(f' | {" | ".join(list(self.table[0].keys()))} |') + lines.extend(f' | {" | ".join(list(row.values()))} |' for row in self.table) return '\n'.join(lines) @@ -62,22 +63,22 @@ def impl(self) -> str: source_lines[0] = dedent(source_lines[0].replace('def ', f'def {self.name}_')) source = '\n'.join(source_lines) - return f'''@then(u'run validator {self.name}_{self.implementation.__name__}') + return f"""@then(u'run validator {self.name}_{self.implementation.__name__}') def {self.name}_{self.implementation.__name__}_wrapper(context: Context) -> None: {dedent(source)} if on_local(context) or on_worker(context): {self.name}_{self.implementation.__name__}(context) -''' +""" class End2EndFixture: _tmp_path_factory: TempPathFactory - _env: Dict[str, str] - _validators: Dict[Optional[str], List[End2EndValidator]] + _env: dict[str, str] + _validators: dict[Optional[str], list[End2EndValidator]] _distributed: bool - _after_features: Dict[str, Callable[[Context, Feature], None]] - _before_features: Dict[str, Callable[[Context, Feature], None]] + _after_features: dict[str, Callable[[Context, Feature], None]] + _before_features: dict[str, Callable[[Context, Feature], None]] _root: Optional[Path] _port: Optional[int] = None @@ -87,9 +88,9 @@ class End2EndFixture: profile: Optional[Profile] - def __init__(self, tmp_path_factory: TempPathFactory, distributed: bool) -> None: + def __init__(self, tmp_path_factory: TempPathFactory, *, distributed: bool) -> None: self._tmp_path_factory = tmp_path_factory - self.cwd = Path(getcwd()) + self.cwd = Path.cwd() self._env = {} self._validators = {} self._root = None @@ -101,17 +102,19 @@ def __init__(self, tmp_path_factory: TempPathFactory, distributed: bool) -> None @property def mode_root(self) -> Path: if self._root is None: - raise AttributeError('root is not set') + message = 'root is not set' + raise AttributeError(message) if self._distributed: return Path('/srv/grizzly') - else: - return self._root + + return self._root @property def root(self) -> Path: if self._root is None: - raise AttributeError('root is not set') + message = 'root is not set' + raise AttributeError(message) return self._root @@ -128,10 +131,7 @@ def webserver_port(self) -> int: @property def host(self) -> str: - if self._distributed: - host = 'master' - else: - host = 'localhost' + host = 'master' if self._distributed else 'localhost' return f'{host}:{self.webserver_port}' @@ -139,7 +139,7 @@ def find_free_port(self) -> int: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: sock.bind(('', 0)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - return cast(int, sock.getsockname()[1]) + return cast('int', sock.getsockname()[1]) def inject_webserver_module(self, path: Path) -> None: assert self._tmp_path_factory._basetemp is not None @@ -150,7 +150,7 @@ def inject_webserver_module(self, path: Path) -> None: webserver_destination.write_text(webserver_source.read_text()) def start_webserver_step_impl(self, port: int) -> str: - return f''' + return f""" @then(u'start webserver on master port "{port}"') def step_start_webserver(context: Context) -> None: @@ -166,9 +166,9 @@ def step_start_webserver(context: Context) -> None: webserver = webserver_module.Webserver({port}) webserver.start() -''' +""" - def __enter__(self) -> 'End2EndFixture': + def __enter__(self) -> Self: if environ.get('PROFILE', None) is not None: self.profile = Profile() self.profile.enable() @@ -184,7 +184,7 @@ def __enter__(self) -> 'End2EndFixture': # create virtualenv rc, output = run_command( [sys.executable, '-m', 'venv', virtual_env_path.name], - cwd=str(self.root), + cwd=self.root, ) try: @@ -202,7 +202,7 @@ def __enter__(self) -> 'End2EndFixture': virtual_env_bin_dir = 'bin' self._env.update({ - 'PATH': f'{str(virtual_env_path / virtual_env_bin_dir)}{pathsep}{path}', + 'PATH': Path.joinpath(virtual_env_path, virtual_env_bin_dir, path).as_posix(), 'VIRTUAL_ENV': str(virtual_env_path), 'PYTHONPATH': environ.get('PYTHONPATH', '.'), 'HOME': environ.get('HOME', '/'), @@ -225,7 +225,7 @@ def __enter__(self) -> 'End2EndFixture': # python 3.6.x is vendord with pip 18.x, which is too old! rc, output = run_command( ['python', '-m', 'pip', 'install', '--upgrade', 'pip'], - cwd=str(Path.cwd()), + cwd=Path.cwd(), env=self._env, ) @@ -237,7 +237,7 @@ def __enter__(self) -> 'End2EndFixture': rc, output = run_command( ['python', '-m', 'pip', 'install', '.'], - cwd=str(Path.cwd()), + cwd=Path.cwd(), env=self._env, ) @@ -251,7 +251,7 @@ def __enter__(self) -> 'End2EndFixture': def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc: Optional[BaseException], traceback: Optional[TracebackType], ) -> Literal[True]: @@ -260,10 +260,8 @@ def __exit__( if exc is None: if environ.get('KEEP_FILES', None) is None: - try: + with suppress(AttributeError): rm_rf(self.root) - except AttributeError: - pass else: print(self._root) @@ -277,7 +275,7 @@ def add_validator( self, implementation: Callable[[Context], None], scenario: Optional[str] = None, - table: Optional[List[Dict[str, str]]] = None, + table: Optional[list[dict[str, str]]] = None, ) -> None: callee = inspect.stack()[1].function @@ -294,22 +292,10 @@ def add_before_feature(self, implementation: Callable[[Context, Feature], None]) callee = inspect.stack()[1].function self._before_features[callee] = implementation - def create_feature(self, contents: str, name: Optional[str] = None, identifier: Optional[str] = None) -> str: - if name is None: - name = inspect.stack()[1].function - - if identifier is not None: - identifier = sha1(identifier.encode()).hexdigest()[:8] - name = f'{name}_{identifier}' - - feature_lines = contents.strip().split('\n') - feature_lines[0] = f'Feature: {name}' - steps_file = self.root / 'features' / 'steps' / 'steps.py' - environment_file = self.root / 'features' / 'environment.py' - + def modify_feature(self, feature_lines: list[str]) -> list[str]: scenario: Optional[str] = None indentation = ' ' - modified_feature_lines: List[str] = [] + modified_feature_lines: list[str] = [] offset = 0 # number of added steps for nr, line in enumerate(feature_lines): @@ -323,9 +309,8 @@ def create_feature(self, contents: str, name: Optional[str] = None, identifier: validators = self._validators.get(scenario, self._validators.get(None, None)) if validators is not None: for validator in validators: - nr += offset validator_expression = indent(f'{validator.expression}', prefix=indentation * 2) - index = nr + index = nr + offset while modified_feature_lines[index].strip() == '' or 'Scenario:' in modified_feature_lines[index]: index -= 1 @@ -340,21 +325,38 @@ def create_feature(self, contents: str, name: Optional[str] = None, identifier: modified_feature_lines.append('') + return modified_feature_lines + + def create_feature(self, contents: str, name: Optional[str] = None, identifier: Optional[str] = None) -> str: + if name is None: + name = inspect.stack()[1].function + + if identifier is not None: + identifier = sha1(identifier.encode()).hexdigest()[:8] # noqa: S324 + name = f'{name}_{identifier}' + + feature_lines = contents.strip().split('\n') + feature_lines[0] = f'Feature: {name}' + steps_file = self.root / 'features' / 'steps' / 'steps.py' + environment_file = self.root / 'features' / 'environment.py' + + modified_feature_lines = self.modify_feature(feature_lines) + contents = '\n'.join(modified_feature_lines) # write feature file - with open(self.root / 'features' / f'{name}.feature', 'w+') as fd: + with (self.root / 'features' / f'{name}.feature').open('w+') as fd: fd.write(contents) feature_file_name = fd.name.replace(f'{self.root}/', '') # cache current step implementations - with open(steps_file, 'r') as fd: + with steps_file.open('r') as fd: steps_impl = fd.read() # add step implementations - with open(steps_file, 'a') as fd: - added_validators: List[str] = [] + with steps_file.open('a') as fd: + added_validators: list[str] = [] for validators in self._validators.values(): for validator in validators: # write expression and step implementation to steps/steps.py @@ -365,19 +367,19 @@ def create_feature(self, contents: str, name: Optional[str] = None, identifier: added_validators = [] # add after_feature hook, always write all of 'em - with open(environment_file, 'w') as fd: + with environment_file.open('w') as fd: fd.write('from typing import Any, Tuple, Dict, cast\n\n') fd.write('from behave.runner import Context\n') fd.write('from behave.model import Feature\n') fd.write('from grizzly.context import GrizzlyContext\n') - fd.write(( + fd.write( 'from grizzly.environment import before_feature as grizzly_before_feature, ' - 'after_feature as grizzly_after_feature, before_scenario, after_scenario, before_step\n\n' - )) + 'after_feature as grizzly_after_feature, before_scenario, after_scenario, before_step\n\n', + ) fd.write('def before_feature(context: Context, feature: Feature, *args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> None:\n') if len(self._before_features) > 0: - for feature_name in self._before_features.keys(): + for feature_name in self._before_features: fd.write(f' if feature.name == "{feature_name}":\n') fd.write(f' {feature_name}_before_feature(context, feature)\n\n') fd.write(' grizzly_before_feature(context, feature)\n\n') @@ -392,7 +394,7 @@ def create_feature(self, contents: str, name: Optional[str] = None, identifier: fd.write('def after_feature(context: Context, feature: Feature, *args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> None:\n') fd.write(' grizzly_after_feature(context, feature)\n\n') if len(self._after_features) > 0: - for feature_name in self._after_features.keys(): + for feature_name in self._after_features: fd.write(f' if feature.name == "{feature_name}":\n') fd.write(f' {feature_name}_after_feature(context, feature)\n\n') @@ -410,26 +412,27 @@ def create_feature(self, contents: str, name: Optional[str] = None, identifier: def execute( self, - feature_file: str, + feature_file: Path, env_conf_file: Optional[str] = None, - testdata: Optional[Dict[str, str]] = None, - cwd: Optional[str] = None, - arguments: Optional[List[str]] = None, - ) -> Tuple[int, List[str]]: + testdata: Optional[dict[str, str]] = None, + cwd: Optional[Path] = None, + arguments: Optional[list[str]] = None, + ) -> tuple[int, list[str]]: if arguments is None: arguments = [] - command = [ + + command: list[str] = [ 'grizzly-cli', self.mode, 'run', *arguments, '--yes', '--verbose', - feature_file, + feature_file.as_posix(), ] if self._distributed: - command = command[:2] + ['--project-name', self.root.name] + command[2:] + command = [*command[:2], '--project-name', self.root.name, *command[2:]] if env_conf_file is not None: command += ['-e', env_conf_file] @@ -440,7 +443,7 @@ def execute( rc, output = run_command( command, - cwd=cwd or str(self.mode_root), + cwd=cwd or self.mode_root, env=self._env, ) @@ -454,7 +457,7 @@ def execute( command = ['docker', 'container', 'logs', f'{self.root.name}-{getuser()}_{container}_1'] _, output = run_command( command, - cwd=str(self.root), + cwd=self.root, env=self._env, ) diff --git a/tests/helpers.py b/tests/helpers.py index 39e5b20..9df841d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,11 +1,12 @@ -import subprocess +from __future__ import annotations + import os +import subprocess import sys - from abc import ABCMeta -from typing import Any, Dict, Optional, Tuple, List, Generator, cast +from contextlib import contextmanager, suppress from pathlib import Path -from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Optional from behave.model import Scenario, Step from setuptools_scm import Configuration as SetuptoolsScmConfiguration @@ -13,11 +14,16 @@ from grizzly_cli.utils import rm_rf +if TYPE_CHECKING: + from collections.abc import Generator + __all__ = ['rm_rf'] -def CaseInsensitive(value: str) -> object: +def CaseInsensitive(value: str) -> object: # noqa: N802 class Wrapped(str): + __slots__ = () + def __eq__(self, other: object) -> bool: return isinstance(other, str) and other.lower() == value.lower() @@ -27,16 +33,19 @@ def __ne__(self, other: object) -> bool: def __neq__(self, other: object) -> bool: return self.__ne__(other) + def __hash__(self) -> int: + return hash(self) + return Wrapped() -def run_command(command: List[str], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None, stdin: Optional[str] = None) -> Tuple[int, List[str]]: - output: List[str] = [] +def run_command(command: list[str], env: Optional[dict[str, str]] = None, cwd: Optional[Path] = None, stdin: Optional[str] = None) -> tuple[int, list[str]]: + output: list[str] = [] if env is None: env = os.environ.copy() if cwd is None: - cwd = os.getcwd() + cwd = Path.cwd() process = subprocess.Popen( command, @@ -49,7 +58,7 @@ def run_command(command: List[str], env: Optional[Dict[str, str]] = None, cwd: O if stdin is not None: assert process.stdin is not None - process.stdin.write(f'{stdin}\n'.encode('utf-8')) + process.stdin.write(f'{stdin}\n'.encode()) process.stdin.close() try: @@ -72,30 +81,28 @@ def run_command(command: List[str], env: Optional[Dict[str, str]] = None, cwd: O except KeyboardInterrupt: pass finally: - try: + with suppress(Exception): process.kill() - except Exception: - pass process.wait() return process.returncode, output -def create_scenario(name: str, background_steps: List[str], steps: List[str]) -> Scenario: +def create_scenario(name: str, background_steps: list[str], steps: list[str]) -> Scenario: scenario = Scenario('', '', '', name) for background_step in background_steps: [keyword, name] = background_step.split(' ', 1) - step = Step('', '', keyword.strip(), keyword.strip(), name.strip()) + behave_step = Step('', '', keyword.strip(), keyword.strip(), name.strip()) if scenario._background_steps is None: scenario._background_steps = [] - scenario._background_steps.append(step) + scenario._background_steps.append(behave_step) for step in steps: [keyword, name] = step.split(' ', 1) - step = Step('', '', keyword.strip(), keyword.strip(), name.strip()) - scenario.steps.append(step) + behave_step = Step('', '', keyword.strip(), keyword.strip(), name.strip()) + scenario.steps.append(behave_step) return scenario @@ -103,11 +110,11 @@ def create_scenario(name: str, background_steps: List[str], steps: List[str]) -> def get_current_version() -> str: root = (Path(__file__).parent / '..').resolve() - version = setuptools_scm_get_version(SetuptoolsScmConfiguration.from_file(str(root / 'pyproject.toml'), str(root)), True) + version = setuptools_scm_get_version(SetuptoolsScmConfiguration.from_file(str(root / 'pyproject.toml'), root.as_posix()), force_write_version_files=True) - assert version is not None, f'setuptools-scm was not able to get current version for {str(root)}' + assert version is not None, f'setuptools-scm was not able to get current version for {root.as_posix()}' - return cast(str, version) + return version # type: ignore[no-any-return] @contextmanager @@ -145,6 +152,9 @@ def __repr__(self) -> str: return ''.join(representation) + def __hash__(self) -> int: + return hash(self) + for c in cls: WrappedAny.register(c) @@ -173,6 +183,9 @@ def __repr__(self) -> str: info = ', '.join([f"{key}={value}" for key, value in values.items()]) return f'' + def __hash__(self) -> int: + return hash(self) + if len(value) > 0 and len(values) > 0: message = 'cannot use both positional and named arguments' raise RuntimeError(message) diff --git a/tests/unit/argparse/bashcompletion/test__init__.py b/tests/unit/argparse/bashcompletion/test__init__.py index 235f51d..07b97cc 100644 --- a/tests/unit/argparse/bashcompletion/test__init__.py +++ b/tests/unit/argparse/bashcompletion/test__init__.py @@ -1,23 +1,24 @@ +from __future__ import annotations + import argparse import inspect - -from typing import Optional, Generator -from os import path, chdir, getcwd +from os.path import sep +from pathlib import Path +from typing import TYPE_CHECKING, Optional import pytest -from pytest_mock import MockerFixture -from _pytest.capture import CaptureFixture, CaptureResult -from _pytest.tmpdir import TempPathFactory - -from grizzly_cli.argparse.bashcompletion import BashCompleteAction, BashCompletionAction, hook -from grizzly_cli.argparse import ArgumentParser from grizzly_cli.__main__ import _create_parser +from grizzly_cli.argparse import ArgumentParser +from grizzly_cli.argparse.bashcompletion import BashCompleteAction, BashCompletionAction, hook +from tests.helpers import cwd, rm_rf -from tests.helpers import rm_rf - +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Generator -CWD = getcwd() + from _pytest.capture import CaptureFixture, CaptureResult + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture @pytest.fixture @@ -74,11 +75,10 @@ def test_file_structure(tmp_path_factory: TempPathFactory) -> Generator[str, Non file = hidden_dir / 'hidden.txt' file.write_text('hidden.txt file') - chdir(test_context) try: - yield str(test_context) + with cwd(test_context): + yield str(test_context) finally: - chdir(CWD) rm_rf(test_context) @@ -98,12 +98,12 @@ def test___call__(self, capsys: CaptureFixture) -> None: with pytest.raises(SystemExit) as e: action(parser, parser.parse_args([]), None) - assert e.type == SystemExit + assert e.type is SystemExit assert e.value.code == 0 - bash_script_path = path.join(path.dirname(inspect.getfile(action.__class__)), 'bashcompletion.bash') + bash_script_path = Path(inspect.getfile(action.__class__)).parent / 'bashcompletion.bash' - with open(bash_script_path, encoding='utf-8') as fd: + with bash_script_path.open(encoding='utf-8') as fd: bash_script = fd.read().replace('bashcompletion_template', parser.prog) + '\n' capture = capsys.readouterr() @@ -173,34 +173,34 @@ def test_remove_completed(self, test_parser: ArgumentParser) -> None: suggestions = action.get_suggestions(test_parser) all_suggestions = suggestions.copy() - all_options_sorted = sorted(list(all_suggestions.keys())) + all_options_sorted = sorted(all_suggestions.keys()) exclusive_suggestions = action.get_exclusive_suggestions(test_parser) assert action.remove_completed([], suggestions, exclusive_suggestions) == [] - assert sorted(list(suggestions.keys())) == all_options_sorted + assert sorted(suggestions.keys()) == all_options_sorted assert action.remove_completed(['--verbose'], suggestions, exclusive_suggestions) == ['--verbose'] - assert sorted(list(suggestions.keys())) == all_options_sorted + assert sorted(suggestions.keys()) == all_options_sorted assert action.remove_completed(['--verbose', '--file'], suggestions, exclusive_suggestions) == ['--file'] - assert sorted(list(suggestions.keys()) + ['--verbose']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose']) == all_options_sorted assert action.remove_completed(['--verbose', '--file', 'test.txt'], suggestions, exclusive_suggestions) == [] - assert sorted(list(suggestions.keys()) + ['--verbose']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose']) == all_options_sorted assert action.remove_completed(['--verbose', '--file', 'test.txt', 'a'], suggestions, exclusive_suggestions) == ['a'] - assert sorted(list(suggestions.keys()) + ['--verbose']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose']) == all_options_sorted # if subparsers are completed, then we move to another parser, with its own arguments assert action.remove_completed(['--verbose', '--file', 'test.txt', '--value'], suggestions, exclusive_suggestions) == ['--value'] - assert sorted(list(suggestions.keys()) + ['--verbose']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose']) == all_options_sorted assert action.remove_completed(['--verbose', '--file', 'test.txt', '--value', '8'], suggestions, exclusive_suggestions) == ['--value', '8'] - assert sorted(list(suggestions.keys()) + ['--verbose']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose']) == all_options_sorted # only one of --foo, --bar, --test is valid (mutually exclusive), so all should be removed from suggestions if one of them is specified assert action.remove_completed(['--verbose', '--file', 'test.txt', '--value', '8', '--foo'], suggestions, exclusive_suggestions) == [] - assert sorted(list(suggestions.keys()) + ['--verbose', '--value', '--foo', '--bar', '--test']) == all_options_sorted + assert sorted([*suggestions.keys(), '--verbose', '--value', '--foo', '--bar', '--test']) == all_options_sorted def test_filter_suggestions(self, test_parser: ArgumentParser) -> None: action = BashCompleteAction(['--bash-complete']) @@ -209,30 +209,30 @@ def test_filter_suggestions(self, test_parser: ArgumentParser) -> None: all_suggestions = suggestions.copy() assert action.filter_suggestions([], suggestions) == all_suggestions - assert sorted(list(action.filter_suggestions(['--'], suggestions).keys())) == sorted(['--help', '--test', '--foo', '--bar', '--value', '--verbose', '--file']) - assert sorted(list(action.filter_suggestions(['--v'], suggestions).keys())) == sorted(['--verbose', '--value']) - assert sorted(list(action.filter_suggestions(['--f'], suggestions).keys())) == sorted(['--file', '--foo']) + assert sorted(action.filter_suggestions(['--'], suggestions).keys()) == sorted(['--help', '--test', '--foo', '--bar', '--value', '--verbose', '--file']) + assert sorted(action.filter_suggestions(['--v'], suggestions).keys()) == sorted(['--verbose', '--value']) + assert sorted(action.filter_suggestions(['--f'], suggestions).keys()) == sorted(['--file', '--foo']) @pytest.mark.parametrize( - 'input,expected', + ('command', 'expected'), [ - ('grizzly-cli ', '-h\n--help\n--version\ninit\nkeyvault\nlocal\ndist\nauth',), + ('grizzly-cli ', '-h\n--help\n--version\ninit\nkeyvault\nlocal\ndist\nauth'), ('grizzly-cli -', '-h\n--help\n--version'), ('grizzly-cli --', '--help\n--version'), ('grizzly-cli lo', 'local'), ('grizzly-cli -h', ''), - ] + ], ) - def test___call__(self, input: str, expected: str, capsys: CaptureFixture) -> None: + def test___call__(self, command: str, expected: str, capsys: CaptureFixture) -> None: parser = _create_parser() with pytest.raises(SystemExit): - parser.parse_args([f'--bash-complete={input}']) + parser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) @pytest.mark.parametrize( - 'input,expected', [ + ('command', 'expected'), [ ( 'grizzly-cli local run ', ( @@ -291,21 +291,21 @@ def test___call__(self, input: str, expected: str, capsys: CaptureFixture) -> No ), ('grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test', 'test.feature\ntest-dir'), ('grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir', 'test-dir'), - (f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{path.sep}', f'test-dir{path.sep}test.feature'), + (f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{sep}', f'test-dir{sep}test.feature'), ( - f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{path.sep}tes', - f'test-dir{path.sep}test.feature', + f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{sep}tes', + f'test-dir{sep}test.feature', ), ( - f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{path.sep}test.feature', + f'grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test-dir{sep}test.feature', '-h\n--help\n--verbose\n-T\n--testdata-variable\n--csv-prefix\n--csv-interval\n--csv-flush-interval\n-l\n--log-file\n--log-dir\n--dump\n--dry-run', ), ('grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test.fe', 'test.feature'), ('grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value --help', ''), ('grizzly-cli local run --yes -T key=value --environment-file test.yaml --testdata-variable key=value --help d', ''), - ] + ], ) - def test___call___local_run(self, input: str, expected: str, capsys: CaptureFixture, test_file_structure: None) -> None: + def test___call___local_run(self, command: str, expected: str, capsys: CaptureFixture, test_file_structure: str) -> None: # noqa: ARG002 capture: Optional[CaptureResult] = None try: @@ -313,10 +313,11 @@ def test___call___local_run(self, input: str, expected: str, capsys: CaptureFixt hook(parser) _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None - subparser: Optional[argparse.ArgumentParser] + subparser: Optional[argparse.ArgumentParser] = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'local': + subparser = possible_subparser break assert subparser is not None @@ -324,29 +325,29 @@ def test___call___local_run(self, input: str, expected: str, capsys: CaptureFixt _subparsers = getattr(subparser, '_subparsers', None) assert _subparsers is not None + subparser = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'run': + subparser = possible_subparser break assert subparser is not None assert subparser.prog == 'grizzly-cli local run' with pytest.raises(SystemExit): - subparser.parse_args([f'--bash-complete={input}']) + subparser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) except: - print(f'input={input}') + print(f'input={command}') print(f'expected={expected}') if capture is not None: print(f'actual={capture.out}') raise - finally: - chdir(CWD) @pytest.mark.parametrize( - 'input,expected', [ + ('command', 'expected'), [ ( 'grizzly-cli dist run ', ( @@ -370,7 +371,7 @@ def test___call___local_run(self, input: str, expected: str, capsys: CaptureFixt ( '-h\n--help\n--verbose\n-T\n--testdata-variable\n-e\n--environment-file\n--csv-prefix\n--csv-interval\n--csv-flush-interval\n' '-l\n--log-file\n--log-dir\n--dump\n--dry-run' - ) + ), ), ('grizzly-cli dist run --help --yes', ''), ('grizzly-cli dist run --yes -T', ''), @@ -407,9 +408,9 @@ def test___call___local_run(self, input: str, expected: str, capsys: CaptureFixt ('grizzly-cli dist run --yes -T key=value --environment-file test.yaml --testdata-variable key=value test.fe', 'test.feature'), ('grizzly-cli dist run --yes -T key=value --environment-file test.yaml --testdata-variable key=value --help', ''), ('grizzly-cli dist run --yes -T key=value --environment-file test.yaml --testdata-variable key=value --help d', ''), - ] + ], ) - def test___call___dist_run(self, input: str, expected: str, capsys: CaptureFixture, test_file_structure: None) -> None: + def test___call___dist_run(self, command: str, expected: str, capsys: CaptureFixture, test_file_structure: str) -> None: # noqa: ARG002 capture: Optional[CaptureResult] = None try: @@ -417,10 +418,11 @@ def test___call___dist_run(self, input: str, expected: str, capsys: CaptureFixtu hook(parser) _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None - subparser: Optional[argparse.ArgumentParser] + subparser: Optional[argparse.ArgumentParser] = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'dist': + subparser = possible_subparser break assert subparser is not None @@ -428,36 +430,36 @@ def test___call___dist_run(self, input: str, expected: str, capsys: CaptureFixtu _subparsers = getattr(subparser, '_subparsers', None) assert _subparsers is not None + subparser = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'run': + subparser = possible_subparser break assert subparser is not None assert subparser.prog == 'grizzly-cli dist run' with pytest.raises(SystemExit): - subparser.parse_args([f'--bash-complete={input}']) + subparser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) except: - print(f'input={input}') + print(f'input={command}') print(f'expected={expected}') if capture is not None: print(f'actual={capture.out}') raise - finally: - chdir(CWD) @pytest.mark.parametrize( - 'input,expected', + ('command', 'expected'), [ ( 'grizzly-cli dist', ( '-h\n--help\n--workers\n--id\n--limit-nofile\n--health-retries\n--health-timeout\n--health-interval\n--registry\n' '--tty\n--wait-for-worker\n--project-name\n--force-build\n--build\n--validate-config\nbuild\nclean\nrun' - ) + ), ), ( 'grizzly-cli dist -', @@ -497,7 +499,7 @@ def test___call___dist_run(self, input: str, expected: str, capsys: CaptureFixtu ), ], ) - def test___call__dist(self, input: str, expected: str, capsys: CaptureFixture, test_file_structure: None) -> None: + def test___call__dist(self, command: str, expected: str, capsys: CaptureFixture, test_file_structure: str) -> None: # noqa: ARG002 capture: Optional[CaptureResult] = None try: @@ -505,30 +507,29 @@ def test___call__dist(self, input: str, expected: str, capsys: CaptureFixture, t hook(parser) _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None - subparser: Optional[argparse.ArgumentParser] + subparser: Optional[argparse.ArgumentParser] = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'dist': + subparser = possible_subparser break assert subparser is not None assert subparser.prog == 'grizzly-cli dist' with pytest.raises(SystemExit): - subparser.parse_args([f'--bash-complete={input}']) + subparser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) except: - print(f'input={input}') + print(f'input={command}') print(f'expected={expected}') if capture is not None: print(f'actual={capture.out}') raise - finally: - chdir(CWD) @pytest.mark.parametrize( - 'input,expected', + ('command', 'expected'), [ ( 'grizzly-cli dist build', @@ -556,7 +557,7 @@ def test___call__dist(self, input: str, expected: str, capsys: CaptureFixture, t ), ], ) - def test___call__dist_build(self, input: str, expected: str, capsys: CaptureFixture, test_file_structure: None) -> None: + def test___call__dist_build(self, command: str, expected: str, capsys: CaptureFixture, test_file_structure: str) -> None: # noqa: ARG002 capture: Optional[CaptureResult] = None try: @@ -564,10 +565,11 @@ def test___call__dist_build(self, input: str, expected: str, capsys: CaptureFixt hook(parser) _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None - subparser: Optional[argparse.ArgumentParser] + subparser: Optional[argparse.ArgumentParser] = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'dist': + subparser = possible_subparser break assert subparser is not None @@ -575,29 +577,29 @@ def test___call__dist_build(self, input: str, expected: str, capsys: CaptureFixt _subparsers = getattr(subparser, '_subparsers', None) assert _subparsers is not None + subparser = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'build': + subparser = possible_subparser break assert subparser is not None assert subparser.prog == 'grizzly-cli dist build' with pytest.raises(SystemExit): - subparser.parse_args([f'--bash-complete={input}']) + subparser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) except: - print(f'input={input}') + print(f'input={command}') print(f'expected={expected}') if capture is not None: print(f'actual={capture.out}') raise - finally: - chdir(CWD) @pytest.mark.parametrize( - 'input,expected', + ('command', 'expected'), [ ( 'grizzly-cli dist clean', @@ -609,7 +611,7 @@ def test___call__dist_build(self, input: str, expected: str, capsys: CaptureFixt ), ], ) - def test___call__dist_clean(self, input: str, expected: str, capsys: CaptureFixture, test_file_structure: None) -> None: + def test___call__dist_clean(self, command: str, expected: str, capsys: CaptureFixture, test_file_structure: str) -> None: # noqa: ARG002 capture: Optional[CaptureResult] = None try: @@ -617,10 +619,11 @@ def test___call__dist_clean(self, input: str, expected: str, capsys: CaptureFixt hook(parser) _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None - subparser: Optional[argparse.ArgumentParser] + subparser: Optional[argparse.ArgumentParser] = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'dist': + subparser = possible_subparser break assert subparser is not None @@ -628,26 +631,26 @@ def test___call__dist_clean(self, input: str, expected: str, capsys: CaptureFixt _subparsers = getattr(subparser, '_subparsers', None) assert _subparsers is not None + subparser = None for subparsers in _subparsers._group_actions: - for name, subparser in subparsers.choices.items(): + for name, possible_subparser in subparsers.choices.items(): if name == 'clean': + subparser = possible_subparser break assert subparser is not None assert subparser.prog == 'grizzly-cli dist clean' with pytest.raises(SystemExit): - subparser.parse_args([f'--bash-complete={input}']) + subparser.parse_args([f'--bash-complete={command}']) capture = capsys.readouterr() assert sorted(capture.out.split('\n')) == sorted(f'{expected}\n'.split('\n')) except: - print(f'input={input}') + print(f'input={command}') print(f'expected={expected}') if capture is not None: print(f'actual={capture.out}') raise - finally: - chdir(CWD) def test_hook(mocker: MockerFixture) -> None: diff --git a/tests/unit/argparse/bashcompletion/test_types.py b/tests/unit/argparse/bashcompletion/test_types.py index 0540b87..893f29a 100644 --- a/tests/unit/argparse/bashcompletion/test_types.py +++ b/tests/unit/argparse/bashcompletion/test_types.py @@ -1,16 +1,16 @@ -from os import chdir, getcwd, sep +from __future__ import annotations + from argparse import ArgumentTypeError +from os import sep +from typing import TYPE_CHECKING import pytest -from _pytest.tmpdir import TempPathFactory - from grizzly_cli.argparse.bashcompletion.types import BashCompletionTypes +from tests.helpers import cwd, rm_rf -from tests.helpers import rm_rf - - -CWD = getcwd() +if TYPE_CHECKING: + from _pytest.tmpdir import TempPathFactory class TestBashCompletionTypes: @@ -35,38 +35,35 @@ def test___call__(self, tmp_path_factory: TempPathFactory) -> None: file = test_context / 'test.xml' file.touch() file.write_text('test.xml file') - test_context_root = str(test_context) - - chdir(test_context_root) try: - impl = BashCompletionTypes.File('*.txt') + with cwd(test_context): + impl = BashCompletionTypes.File('*.txt') - with pytest.raises(ArgumentTypeError) as ate: - impl('non-existing-directory/') - assert 'non-existing-directory/ does not exist' in str(ate) + with pytest.raises(ArgumentTypeError) as ate: + impl('non-existing-directory/') + assert 'non-existing-directory/ does not exist' in str(ate) - with pytest.raises(ArgumentTypeError) as ate: - impl('test-dir/') - assert 'test-dir/ is not a file' in str(ate) + with pytest.raises(ArgumentTypeError) as ate: + impl('test-dir/') + assert 'test-dir/ is not a file' in str(ate) - with pytest.raises(ArgumentTypeError) as ate: - impl('test.xml') - assert 'test.xml does not match *.txt' in str(ate) + with pytest.raises(ArgumentTypeError) as ate: + impl('test.xml') + assert 'test.xml does not match *.txt' in str(ate) - assert impl('test.txt') == 'test.txt' + assert impl('test.txt') == 'test.txt' - impl = BashCompletionTypes.File('*.txt', '*.json') + impl = BashCompletionTypes.File('*.txt', '*.json') - with pytest.raises(ArgumentTypeError) as ate: - impl('test.xml') - assert 'test.xml does not match *.txt' in str(ate) + with pytest.raises(ArgumentTypeError) as ate: + impl('test.xml') + assert 'test.xml does not match *.txt' in str(ate) - assert impl('test.txt') == 'test.txt' - assert impl('test.json') == 'test.json' + assert impl('test.txt') == 'test.txt' + assert impl('test.json') == 'test.json' finally: - chdir(CWD) - rm_rf(test_context_root) + rm_rf(test_context) def test_list_files(self, tmp_path_factory: TempPathFactory) -> None: test_context = tmp_path_factory.mktemp('test_context') @@ -81,36 +78,32 @@ def test_list_files(self, tmp_path_factory: TempPathFactory) -> None: hidden_dir = test_context / '.hidden' hidden_dir.mkdir() (hidden_dir / 'hidden.txt').write_text('hidden.txt file') - test_context_root = str(test_context) - - chdir(test_context_root) - try: - impl = BashCompletionTypes.File('*.txt') - assert impl.list_files(None) == { - 'test.txt': 'file', - 'test-dir': 'dir', - } - assert impl.list_files('te') == { - 'test.txt': 'file', - 'test-dir': 'dir', - } - - assert impl.list_files('test-') == { - 'test-dir': 'dir', - } - - assert impl.list_files(f'test-dir{sep}') == { - f'test-dir{sep}test.txt': 'file', - } - - impl = BashCompletionTypes.File('*.txt', '*.json', '*.xml') - assert impl.list_files('te') == { - 'test.txt': 'file', - 'test.json': 'file', - 'test.xml': 'file', - 'test-dir': 'dir', - } + with cwd(test_context): + impl = BashCompletionTypes.File('*.txt') + assert impl.list_files(None) == { + 'test.txt': 'file', + 'test-dir': 'dir', + } + assert impl.list_files('te') == { + 'test.txt': 'file', + 'test-dir': 'dir', + } + + assert impl.list_files('test-') == { + 'test-dir': 'dir', + } + + assert impl.list_files(f'test-dir{sep}') == { + f'test-dir{sep}test.txt': 'file', + } + + impl = BashCompletionTypes.File('*.txt', '*.json', '*.xml') + assert impl.list_files('te') == { + 'test.txt': 'file', + 'test.json': 'file', + 'test.xml': 'file', + 'test-dir': 'dir', + } finally: - chdir(CWD) - rm_rf(test_context_root) + rm_rf(test_context) diff --git a/tests/unit/argparse/test___init__.py b/tests/unit/argparse/test___init__.py index 70590ce..5d67532 100644 --- a/tests/unit/argparse/test___init__.py +++ b/tests/unit/argparse/test___init__.py @@ -1,15 +1,20 @@ -from argparse import ArgumentError, ArgumentParser as CoreArgumentParser -from typing import Tuple -import pytest +from __future__ import annotations + +from typing import TYPE_CHECKING -from _pytest.capture import CaptureFixture +import pytest from grizzly_cli.argparse import ArgumentParser from grizzly_cli.argparse.markdown import MarkdownFormatter +if TYPE_CHECKING: + from argparse import ArgumentParser as CoreArgumentParser + + from _pytest.capture import CaptureFixture + @pytest.fixture -def parsers() -> Tuple[CoreArgumentParser, ...]: +def parsers() -> tuple[CoreArgumentParser, ...]: parser = ArgumentParser(prog='test-prog', description='test parser', markdown_help=True, bash_completion=True) parser.add_argument('--root-parser', type=str, default='root-parser', help='root parser argument') @@ -76,21 +81,21 @@ def test_error_no_help(self, capsys: CaptureFixture) -> None: parser = ArgumentParser(markdown_help=False, description='test parser', prog='test-prog') with pytest.raises(SystemExit) as e: parser.error_no_help('test test test') - assert e.type == SystemExit + assert e.type is SystemExit assert e.value.code == 2 capture = capsys.readouterr() assert capture.err == 'test-prog: error: test test test\n' assert capture.out == '' - def test_print_help_HelpFormatter(self, capsys: CaptureFixture, parsers: Tuple[ArgumentParser, ...]) -> None: + def test_print_help_help_formatter(self, capsys: CaptureFixture, parsers: tuple[ArgumentParser, ...]) -> None: parser, a_sub_parser, b_sub_parser = parsers parser.markdown_help = False parser.print_help() capture = capsys.readouterr() - assert capture.out == '''usage: test-prog [-h] [--root-parser ROOT_PARSER] {a,b} ... + assert capture.out == """usage: test-prog [-h] [--root-parser ROOT_PARSER] {a,b} ... test parser @@ -103,12 +108,12 @@ def test_print_help_HelpFormatter(self, capsys: CaptureFixture, parsers: Tuple[A sub parser a {a,b} -''' +""" parser.markdown_help = True parser.print_help() capture = capsys.readouterr() - assert capture.out == '''usage: test-prog [-h] [--root-parser ROOT_PARSER] {a,b} ... + assert capture.out == """usage: test-prog [-h] [--root-parser ROOT_PARSER] {a,b} ... test parser @@ -121,48 +126,48 @@ def test_print_help_HelpFormatter(self, capsys: CaptureFixture, parsers: Tuple[A sub parser a {a,b} -''' +""" a_sub_parser.markdown_help = False a_sub_parser.print_help() capture = capsys.readouterr() - assert capture.out == '''usage: test-prog a [-h] [--a-parser] + assert capture.out == """usage: test-prog a [-h] [--a-parser] optional arguments: -h, --help show this help message and exit --a-parser a parser [argument](http://localhost) -''' +""" a_sub_parser.markdown_help = True a_sub_parser.print_help() capture = capsys.readouterr() - assert capture.out == '''usage: test-prog a [-h] [--a-parser] + assert capture.out == """usage: test-prog a [-h] [--a-parser] optional arguments: -h, --help show this help message and exit --a-parser a parser argument -''' +""" b_sub_parser.print_help() capture = capsys.readouterr() - assert capture.out == '''usage: test-prog b [-h] [--b-parser B_PARSER] + assert capture.out == """usage: test-prog b [-h] [--b-parser B_PARSER] optional arguments: -h, --help show this help message and exit --b-parser B_PARSER b parser argument -''' +""" - def test_print_help_MarkdownFormatter(self, capsys: CaptureFixture, parsers: Tuple[ArgumentParser, ...]) -> None: + def test_print_help_markdown_formatter(self, capsys: CaptureFixture, parsers: tuple[ArgumentParser, ...]) -> None: parser, _, _ = parsers parser.formatter_class = MarkdownFormatter.factory(0) parser.print_help() capture = capsys.readouterr() - assert capture.out == '''# `test-prog` + assert capture.out == """# `test-prog` ### Usage @@ -178,9 +183,9 @@ def test_print_help_MarkdownFormatter(self, capsys: CaptureFixture, parsers: Tup ## Subcommands sub parser a -''' +""" - def test_parse_args(self, parsers: Tuple[ArgumentParser, ...]) -> None: + def test_parse_args(self, parsers: tuple[ArgumentParser, ...]) -> None: parser, a_sub_parser, b_sub_parser = parsers parser.parse_args([]) @@ -197,24 +202,21 @@ def test_parse_args(self, parsers: Tuple[ArgumentParser, ...]) -> None: all_option_strings = [option for action in b_sub_parser._actions for option in action.option_strings] assert '--bash-complete' in all_option_strings - try: - parser.parse_args([]) - except ArgumentError as e: - pytest.fail(str(e)) + parser.parse_args([]) - def test_md_help_action_from_parser(self, capsys: CaptureFixture, parsers: Tuple[ArgumentParser, ...]) -> None: + def test_md_help_action_from_parser(self, capsys: CaptureFixture, parsers: tuple[ArgumentParser, ...]) -> None: parser, _, _ = parsers with pytest.raises(SystemExit) as e: parser.parse_args(['--md-help']) - assert e.type == SystemExit + assert e.type is SystemExit assert e.value.code == 0 capture = capsys.readouterr() print(capture) - assert capture.out == '''# `test-prog` + assert capture.out == """# `test-prog` test parser @@ -260,4 +262,4 @@ def test_md_help_action_from_parser(self, capsys: CaptureFixture, parsers: Tuple | argument | default | help | | -------- | ------- | ---- | | `--b-parser` | | b parser argument | -''' +""" diff --git a/tests/unit/argparse/test_markdown.py b/tests/unit/argparse/test_markdown.py index af60d97..2a8b148 100644 --- a/tests/unit/argparse/test_markdown.py +++ b/tests/unit/argparse/test_markdown.py @@ -1,14 +1,16 @@ -import argparse +from __future__ import annotations -from typing import cast +import argparse +from typing import TYPE_CHECKING import pytest -from pytest_mock import MockerFixture -from _pytest.capture import CaptureFixture - from grizzly_cli.argparse.markdown import MarkdownFormatter, MarkdownHelpAction +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from pytest_mock import MockerFixture + class TestMarkdownHelpAction: def test___init__(self) -> None: @@ -28,7 +30,7 @@ def test___call__(self, mocker: MockerFixture) -> None: with pytest.raises(SystemExit) as e: parser.parse_args(['--md-help']) - assert e.type == SystemExit + assert e.type is SystemExit assert e.value.code == 0 assert print_help.call_count == 1 @@ -53,14 +55,14 @@ def test_print_help(self, mocker: MockerFixture) -> None: action.print_help(parser) assert print_help.call_count == 4 - assert issubclass(parser.formatter_class, MarkdownFormatter) # type: ignore + assert issubclass(parser.formatter_class, MarkdownFormatter) # type: ignore[arg-type] assert parser._subparsers is not None _subparsers = getattr(parser, '_subparsers', None) assert _subparsers is not None for subparsers in _subparsers._group_actions: for name, subparser in subparsers.choices.items(): - assert issubclass(subparser.formatter_class, MarkdownFormatter) # type: ignore + assert issubclass(subparser.formatter_class, MarkdownFormatter) # type: ignore[arg-type] if name == 'a': _subsubparsers = getattr(subparser, '_subparsers', None) assert _subsubparsers is not None @@ -105,32 +107,32 @@ def test___init__(self) -> None: def test__format_usage(self) -> None: formatter = MarkdownFormatter.factory(0)('test') usage = formatter._format_usage('test', None, None, 'a prefix') - assert usage == ''' + assert usage == """ ### Usage ```bash test ``` -''' +""" parser = argparse.ArgumentParser(prog='test', description='test parser') parser.add_argument('-t', '--test', type=str, required=True, help='test argument') parser.add_argument('file', nargs=1, help='file argument') core_formatter = parser.formatter_class(prog=parser.prog) - usage = core_formatter._format_usage(cast(str, parser.usage), parser._get_positional_actions(), parser._mutually_exclusive_groups, 'a prefix ') - assert usage == '''a prefix test file + usage = core_formatter._format_usage(parser.usage, parser._get_positional_actions(), parser._mutually_exclusive_groups, 'a prefix ') + assert usage == """a prefix test file -''' +""" usage = formatter._format_usage(parser.usage, parser._get_positional_actions(), parser._mutually_exclusive_groups, 'a prefix ') - assert usage == ''' + assert usage == """ ### Usage ```bash test file ``` -''' +""" def test_format_help(self) -> None: formatter = MarkdownFormatter.factory(0)('test') @@ -139,7 +141,7 @@ def test_format_help(self) -> None: def test_format_text(self) -> None: formatter = MarkdownFormatter('test-prog') - text = '''%(prog)s is awesome! + text = """%(prog)s is awesome! also, here is a sentence. and here is another one! ```bash @@ -147,9 +149,9 @@ def test_format_text(self) -> None: ``` you cannot belive it, it's another sentence. -''' +""" print(formatter._format_text(text)) - assert formatter._format_text(text) == '''test-prog is awesome! + assert formatter._format_text(text) == """test-prog is awesome! also, here is a sentence. and here is another one! ```bash @@ -157,7 +159,7 @@ def test_format_text(self) -> None: ``` you cannot belive it, it's another sentence. -''' +""" def test_start_section(self) -> None: formatter = MarkdownFormatter.factory(0)('test-prog') @@ -169,7 +171,7 @@ def test_start_section(self) -> None: assert formatter._current_section.parent is formatter._root_section assert formatter._current_section.heading == '## Test-section-01' assert len(formatter._current_section.items) == 0 - assert formatter._current_section.parent.items[0] == (formatter._current_section.format_help, [],) + assert next(iter(formatter._current_section.parent.items)) == (formatter._current_section.format_help, []) def test__format_action(self) -> None: formatter = MarkdownFormatter.factory(0)('test-prog') @@ -226,7 +228,7 @@ def test_format_help(self, capsys: CaptureFixture) -> None: format_help_text = formatter._current_section.format_help() assert capsys.readouterr().out == '' - assert format_help_text == ''' + assert format_help_text == """ ## Root section @@ -236,7 +238,7 @@ def test_format_help(self, capsys: CaptureFixture) -> None: | `--root-const` | `True` | | -''' +""" formatter = MarkdownFormatter('test-prog') formatter.level = 1 @@ -254,7 +256,7 @@ def test_format_help(self, capsys: CaptureFixture) -> None: format_help_text = formatter._current_section.format_help() assert capsys.readouterr().out == '\n' # @TODO: whyyyyyyyyyy?! print(format_help_text) - assert format_help_text == ''' + assert format_help_text == """ #### Root section @@ -264,4 +266,4 @@ def test_format_help(self, capsys: CaptureFixture) -> None: | `--root-const` | `True` | | -''' +""" diff --git a/tests/unit/distributed/test___init__.py b/tests/unit/distributed/test___init__.py index f13d02a..0cd78ca 100644 --- a/tests/unit/distributed/test___init__.py +++ b/tests/unit/distributed/test___init__.py @@ -1,22 +1,23 @@ -import sys -import json +from __future__ import annotations -from os import getcwd, environ -from tempfile import gettempdir +import json +import sys from argparse import ArgumentParser, Namespace +from contextlib import suppress from datetime import datetime, timezone +from os import environ +from tempfile import gettempdir +from typing import TYPE_CHECKING import pytest -from _pytest.capture import CaptureFixture -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture - +from grizzly_cli.distributed import create_parser, distributed, distributed_run from grizzly_cli.utils import RunCommandResult, rm_rf -from grizzly_cli.distributed import create_parser, distributed_run, distributed - -CWD = getcwd() +if TYPE_CHECKING: # pragma: no cover + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture def test_distributed(mocker: MockerFixture) -> None: @@ -54,12 +55,11 @@ def test_distributed(mocker: MockerFixture) -> None: assert args[0] is arguments arguments = Namespace(subcommand='foo') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='unknown subcommand foo'): distributed(arguments) - assert 'unknown subcommand foo' == str(ve.value) -def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: +def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') (test_context / 'test.feature').write_text('Feature:') @@ -68,10 +68,10 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path do_build_mock = mocker.patch('grizzly_cli.distributed.do_build', return_value=None) list_images_mock = mocker.patch('grizzly_cli.distributed.list_images', return_value=None) - import grizzly_cli.distributed - mocker.patch.object(grizzly_cli.distributed, 'EXECUTION_CONTEXT', '/tmp/execution-context') - mocker.patch.object(grizzly_cli.distributed, 'STATIC_CONTEXT', '/tmp/static-context') - mocker.patch.object(grizzly_cli.distributed, 'MOUNT_CONTEXT', '/tmp/mount-context') + import grizzly_cli.distributed # noqa: PLC0415 + mocker.patch.object(grizzly_cli.distributed, 'EXECUTION_CONTEXT', '/srv/grizzly/execution-context') + mocker.patch.object(grizzly_cli.distributed, 'STATIC_CONTEXT', '/srv/grizzly/static-context') + mocker.patch.object(grizzly_cli.distributed, 'MOUNT_CONTEXT', '/srv/grizzly/mount-context') mocker.patch.object(grizzly_cli.distributed, 'PROJECT_NAME', 'grizzly-cli-test-project') run_command_result = RunCommandResult(return_code=1) @@ -92,11 +92,11 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path get_default_mtu_mock.return_value = '1500' sys.argv = ['grizzly-cli', 'dist', '--workers', '3', '--tty', 'run', f'{test_context}/test.feature'] arguments = parser.parse_args() - setattr(arguments, 'container_system', 'docker') - setattr(arguments, 'file', ' '.join(arguments.file)) + setattr(arguments, 'container_system', 'docker') # noqa: B010 + setattr(arguments, 'file', ' '.join(arguments.file)) # noqa: B010 # this is set in the devcontainer - for key in environ.keys(): + for key in environ: if key.startswith('GRIZZLY_'): del environ[key] @@ -108,10 +108,8 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path f'grizzly-cli dist --validate-config --workers 3 --tty run {test_context}/test.feature\n' ) - try: + with suppress(KeyError): del environ['GRIZZLY_MTU'] - except KeyError: - pass run_command_mock.return_value = RunCommandResult(return_code=0) do_build_mock.return_value = 255 @@ -127,9 +125,9 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path '!! failed to build grizzly-cli-test-project, rc=255\n' ) assert environ.get('GRIZZLY_MTU', None) == '1500' - assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/tmp/execution-context' - assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/tmp/static-context' - assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/tmp/mount-context' + assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/srv/grizzly/execution-context' + assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/srv/grizzly/static-context' + assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/srv/grizzly/mount-context' assert environ.get('GRIZZLY_PROJECT_NAME', None) == 'grizzly-cli-test-project' assert environ.get('GRIZZLY_USER_TAG', None) == 'test-user' assert environ.get('GRIZZLY_EXPECTED_WORKERS', None) == '3' @@ -147,8 +145,8 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path assert environ.get('GRIZZLY_MOUNT_PATH', None) == '' # this is set in the devcontainer - for key in environ.keys(): - if key.startswith('GRIZZLY_') or key.startswith('LOCUST_'): + for key in environ: + if key.startswith(('GRIZZLY_', 'LOCUST_')): del environ[key] arguments = parser.parse_args([ @@ -165,8 +163,8 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path 'run', f'{test_context}/test.feature', ]) - setattr(arguments, 'container_system', 'docker') - setattr(arguments, 'file', ' '.join(arguments.file)) + setattr(arguments, 'container_system', 'docker') # noqa: B010 + setattr(arguments, 'file', ' '.join(arguments.file)) # noqa: B010 # docker-compose v2 rcr = RunCommandResult(return_code=1) @@ -182,7 +180,7 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path assert distributed_run( arguments, { - 'GRIZZLY_CONFIGURATION_FILE': '/tmp/execution-context/configuration.yaml', + 'GRIZZLY_CONFIGURATION_FILE': '/srv/grizzly/execution-context/configuration.yaml', 'GRIZZLY_TEST_VAR': 'True', }, { @@ -207,14 +205,14 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path assert args[0] == [ 'docker', 'compose', '-p', 'foobar-test-user', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', 'config', ] args, _ = run_command_mock.call_args_list[-2] assert args[0] == [ 'docker', 'compose', '-p', 'foobar-test-user', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', 'up', '--scale', 'worker=3', '--remove-orphans', @@ -223,15 +221,15 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path assert args[0] == [ 'docker', 'compose', '-p', 'foobar-test-user', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', 'stop', ] assert environ.get('GRIZZLY_RUN_FILE', None) == f'{test_context}/test.feature' assert environ.get('GRIZZLY_MTU', None) == '1400' - assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/tmp/execution-context' - assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/tmp/static-context' - assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/tmp/mount-context' + assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/srv/grizzly/execution-context' + assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/srv/grizzly/static-context' + assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/srv/grizzly/mount-context' assert environ.get('GRIZZLY_PROJECT_NAME', None) == 'foobar' assert environ.get('GRIZZLY_USER_TAG', None) == 'test-user' assert environ.get('GRIZZLY_EXPECTED_WORKERS', None) == '3' @@ -251,8 +249,8 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path arguments.project_name = None # this is set in the devcontainer - for key in environ.keys(): - if key.startswith('GRIZZLY_') or key.startswith('LOCUST_'): + for key in environ: + if key.startswith(('GRIZZLY_', 'LOCUST_')): del environ[key] arguments = parser.parse_args([ @@ -267,21 +265,21 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path '--wait-for-worker', '1.25 * WORKER_REPORT_INTERVAL', 'run', f'{test_context}/test.feature', ]) - setattr(arguments, 'container_system', 'docker') - setattr(arguments, 'file', ' '.join(arguments.file)) + setattr(arguments, 'container_system', 'docker') # noqa: B010 + setattr(arguments, 'file', ' '.join(arguments.file)) # noqa: B010 run_command_mock.return_value = None run_command_mock.side_effect = [RunCommandResult(return_code=13)] do_build_mock.return_value = 0 check_output_mock.return_value = None - check_output_mock.side_effect = [json.dumps([{'Source': '/tmp/mount-context', 'Destination': '/tmp'}]), '13'] + check_output_mock.side_effect = [json.dumps([{'Source': '/srv/grizzly/mount-context', 'Destination': '/srv/grizzly'}]), '13'] get_default_mtu_mock.return_value = '1800' list_images_mock.return_value = {'grizzly-cli-test-project': {'test-user': {}}} assert distributed_run( arguments, { - 'GRIZZLY_CONFIGURATION_FILE': '/tmp/execution-context/configuration.yaml', + 'GRIZZLY_CONFIGURATION_FILE': '/srv/grizzly/execution-context/configuration.yaml', 'GRIZZLY_TEST_VAR': 'True', }, { @@ -299,15 +297,15 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path assert args[0] == [ 'docker', 'compose', '-p', 'grizzly-cli-test-project-suffix-test-user', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', 'config', ] assert environ.get('GRIZZLY_RUN_FILE', None) == f'{test_context}/test.feature' assert environ.get('GRIZZLY_MTU', None) == '1800' - assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/tmp/execution-context' - assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/tmp/static-context' - assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/tmp/mount-context' + assert environ.get('GRIZZLY_EXECUTION_CONTEXT', None) == '/srv/grizzly/execution-context' + assert environ.get('GRIZZLY_STATIC_CONTEXT', None) == '/srv/grizzly/static-context' + assert environ.get('GRIZZLY_MOUNT_CONTEXT', None) == '/srv/grizzly/mount-context' assert environ.get('GRIZZLY_PROJECT_NAME', None) == 'grizzly-cli-test-project' assert environ.get('GRIZZLY_USER_TAG', None) == 'test-user' assert environ.get('GRIZZLY_EXPECTED_WORKERS', None) == '1' @@ -325,6 +323,6 @@ def test_distributed_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path finally: rm_rf(test_context) - for key in environ.keys(): + for key in environ: if key.startswith('GRIZZLY_'): del environ[key] diff --git a/tests/unit/distributed/test_build.py b/tests/unit/distributed/test_build.py index b4ffc02..b4b133e 100644 --- a/tests/unit/distributed/test_build.py +++ b/tests/unit/distributed/test_build.py @@ -1,20 +1,22 @@ -import sys +from __future__ import annotations -from os import environ, path, getcwd, chdir +import sys +from argparse import Namespace +from contextlib import suppress from inspect import getfile +from os import environ +from pathlib import Path from socket import gaierror +from typing import TYPE_CHECKING -from _pytest.capture import CaptureFixture -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture - -from argparse import Namespace - +from grizzly_cli.distributed.build import _create_build_command, build, getgid, getuid from grizzly_cli.utils import RunCommandResult, rm_rf -from grizzly_cli.distributed.build import _create_build_command, getgid, getuid, build +from tests.helpers import cwd - -CWD = getcwd() +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture def test_getuid_getgid_nt(mocker: MockerFixture) -> None: @@ -34,7 +36,7 @@ def test__create_build_command(mocker: MockerFixture) -> None: mocker.patch('grizzly_cli.distributed.build.getgid', return_value=2147483647) args = Namespace(container_system='test', local_install=False) - mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', None, ), '2.8.4')) + mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', None), '2.8.4')) assert _create_build_command(args, 'Containerfile.test', 'grizzly-cli:test', '/home/grizzly-cli/') == [ 'test', @@ -51,7 +53,7 @@ def test__create_build_command(mocker: MockerFixture) -> None: '/home/grizzly-cli/', ] - mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', [], ), '2.8.4')) + mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', []), '2.8.4')) args.local_install = True @@ -70,7 +72,7 @@ def test__create_build_command(mocker: MockerFixture) -> None: '/home/grizzly-cli/', ] - mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', ['dev', 'ci', 'mq'], ), '2.8.4')) + mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', ['dev', 'ci', 'mq']), '2.8.4')) assert _create_build_command(args, 'Containerfile.test', 'grizzly-cli:test', '/home/grizzly-cli/') == [ 'test', @@ -171,199 +173,194 @@ def test__create_build_command(mocker: MockerFixture) -> None: ] finally: - try: + with suppress(KeyError): del environ['IBM_MQ_LIB_HOST'] - except KeyError: - pass - try: + + with suppress(KeyError): del environ['IBM_MQ_LIB'] - except KeyError: - pass -def test_build(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: +def test_build(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') try: - chdir(test_context) - mocker.patch('grizzly_cli.EXECUTION_CONTEXT', str(test_context)) - mocker.patch('grizzly_cli.distributed.build.EXECUTION_CONTEXT', str(test_context)) - mocker.patch('grizzly_cli.distributed.build.PROJECT_NAME', 'grizzly-scenarios') - mocker.patch('grizzly_cli.distributed.build.getuser', return_value='test-user') - mocker.patch('grizzly_cli.distributed.build.getuid', return_value=1337) - mocker.patch('grizzly_cli.distributed.build.getgid', return_value=2147483647) - run_command = mocker.patch('grizzly_cli.distributed.build.run_command', side_effect=[ - RunCommandResult(return_code=254), - RunCommandResult(return_code=133), - RunCommandResult(return_code=0), - RunCommandResult(return_code=1), - RunCommandResult(return_code=0), - RunCommandResult(return_code=0), - RunCommandResult(return_code=2), - RunCommandResult(return_code=0), - RunCommandResult(return_code=0), - RunCommandResult(return_code=0), - ]) - setattr(getattr(build, '__wrapped__'), '__value__', str(test_context)) - - test_args = Namespace(container_system='test', force_build=False, project_name=None, local_install=False, no_progress=False, verbose=False) - - static_context = path.realpath(path.join(path.dirname(getfile(_create_build_command)), '..', 'static')) - - assert build(test_args) == 254 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '' - assert run_command.call_count == 1 - args, kwargs = run_command.call_args_list[-1] - - container_file_actual = args[0].pop(14) - container_file_expected = f'{static_context}{path.sep}Containerfile' - - if sys.platform == 'win32': - container_file_actual = container_file_actual.lower() - container_file_expected = container_file_expected.lower() - - assert args[0] == [ - 'test', - 'image', - 'build', - '--ssh', - 'default', - '--build-arg', 'GRIZZLY_EXTRA=base', - '--build-arg', 'GRIZZLY_INSTALL_TYPE=remote', - '--build-arg', 'GRIZZLY_UID=1337', - '--build-arg', 'GRIZZLY_GID=2147483647', - '-f', - '-t', 'grizzly-scenarios:test-user', - str(test_context), - ] - - assert container_file_actual == container_file_expected - - actual_env = kwargs.get('env', None) - assert actual_env is not None - assert actual_env.get('DOCKER_BUILDKIT', None) == environ.get('DOCKER_BUILDKIT', None) - - test_args = Namespace(container_system='docker', force_build=True, local_install=True, project_name='foobar', no_progress=False, verbose=False) - - mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', ['mq', 'dev'], ), '2.8.4')) - - assert build(test_args) == 133 - assert run_command.call_count == 2 - args, kwargs = run_command.call_args_list[-1] - - container_file_actual = args[0].pop(14) - container_file_expected = f'{static_context}{path.sep}Containerfile' - - if sys.platform == 'win32': - container_file_actual = container_file_actual.lower() - container_file_expected = container_file_expected.lower() - - assert args[0] == [ - 'docker', - 'image', - 'build', - '--ssh', - 'default', - '--build-arg', 'GRIZZLY_EXTRA=mq', - '--build-arg', 'GRIZZLY_INSTALL_TYPE=local', - '--build-arg', 'GRIZZLY_UID=1337', - '--build-arg', 'GRIZZLY_GID=2147483647', - '-f', - '-t', 'foobar:test-user', - str(test_context), - '--no-cache' - ] - assert container_file_actual == container_file_expected - - capsys.readouterr() - - actual_env = kwargs.get('env', None) - assert actual_env is not None - assert actual_env.get('DOCKER_BUILDKIT', None) == '1' - - image_name = 'grizzly-scenarios:test-user' - test_args = Namespace( - container_system='docker', - force_build=False, - local_install=False, - project_name=None, - registry='ghcr.io/biometria-se/', - no_progress=False, - verbose=False, - ) - - assert build(test_args) == 1 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - f'\nbuilt image {image_name}\n' - f'\n!! failed to tag image {image_name} -> ghcr.io/biometria-se/{image_name}\n' - ) - - assert run_command.call_count == 4 - - args, kwargs = run_command.call_args_list[-1] - assert args[0] == [ - 'docker', - 'image', - 'tag', - image_name, - f'ghcr.io/biometria-se/{image_name}', - ] - - actual_env = kwargs.get('env', None) - assert actual_env.get('DOCKER_BUILDKIT', None) == '1' - - test_args = Namespace( - container_system='docker', - force_build=True, - no_cache=True, - build=True, - registry='ghcr.io/biometria-se/', - project_name='foobar', - local_install=True, - no_progress=False, - verbose=False, - ) - - image_name = 'foobar:test-user' - assert build(test_args) == 2 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - f'\nbuilt image {image_name}\n' - f'tagged image {image_name} -> ghcr.io/biometria-se/{image_name}\n' - f'\n!! failed to push image ghcr.io/biometria-se/{image_name}\n' - ) - - assert run_command.call_count == 7 - - args, kwargs = run_command.call_args_list[-1] - assert args[0] == [ - 'docker', - 'image', - 'push', - f'ghcr.io/biometria-se/{image_name}', - ] - - actual_env = kwargs.get('env', None) - assert actual_env.get('DOCKER_BUILDKIT', None) == '1' - - assert build(test_args) == 0 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - f'\nbuilt image {image_name}\n' - f'tagged image {image_name} -> ghcr.io/biometria-se/{image_name}\n' - f'pushed image ghcr.io/biometria-se/{image_name}\n' - ) + with cwd(test_context): + mocker.patch('grizzly_cli.EXECUTION_CONTEXT', test_context.as_posix()) + mocker.patch('grizzly_cli.distributed.build.EXECUTION_CONTEXT', test_context.as_posix()) + mocker.patch('grizzly_cli.distributed.build.PROJECT_NAME', 'grizzly-scenarios') + mocker.patch('grizzly_cli.distributed.build.getuser', return_value='test-user') + mocker.patch('grizzly_cli.distributed.build.getuid', return_value=1337) + mocker.patch('grizzly_cli.distributed.build.getgid', return_value=2147483647) + run_command = mocker.patch('grizzly_cli.distributed.build.run_command', side_effect=[ + RunCommandResult(return_code=254), + RunCommandResult(return_code=133), + RunCommandResult(return_code=0), + RunCommandResult(return_code=1), + RunCommandResult(return_code=0), + RunCommandResult(return_code=0), + RunCommandResult(return_code=2), + RunCommandResult(return_code=0), + RunCommandResult(return_code=0), + RunCommandResult(return_code=0), + ]) + setattr(getattr(build, '__wrapped__'), '__value__', test_context.as_posix()) # noqa: B009, B010 + + test_args = Namespace(container_system='test', force_build=False, project_name=None, local_install=False, no_progress=False, verbose=False) + + static_context = Path.joinpath(Path(getfile(_create_build_command)).parent, '..', 'static').resolve() + + assert build(test_args) == 254 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == '' + assert run_command.call_count == 1 + args, kwargs = run_command.call_args_list[-1] + + container_file_actual = args[0].pop(14) + container_file_expected = Path.joinpath(static_context, 'Containerfile').as_posix() + + if sys.platform == 'win32': + container_file_actual = container_file_actual.lower() + container_file_expected = container_file_expected.lower() + + assert args[0] == [ + 'test', + 'image', + 'build', + '--ssh', + 'default', + '--build-arg', 'GRIZZLY_EXTRA=base', + '--build-arg', 'GRIZZLY_INSTALL_TYPE=remote', + '--build-arg', 'GRIZZLY_UID=1337', + '--build-arg', 'GRIZZLY_GID=2147483647', + '-f', + '-t', 'grizzly-scenarios:test-user', + test_context.as_posix(), + ] + + assert container_file_actual == container_file_expected + + actual_env = kwargs.get('env', None) + assert actual_env is not None + assert actual_env.get('DOCKER_BUILDKIT', None) == environ.get('DOCKER_BUILDKIT', None) + + test_args = Namespace(container_system='docker', force_build=True, local_install=True, project_name='foobar', no_progress=False, verbose=False) + + mocker.patch('grizzly_cli.distributed.build.get_dependency_versions', return_value=(('1.1.1', ['mq', 'dev']), '2.8.4')) + + assert build(test_args) == 133 + assert run_command.call_count == 2 + args, kwargs = run_command.call_args_list[-1] + + container_file_actual = args[0].pop(14) + container_file_expected = Path.joinpath(static_context, 'Containerfile').as_posix() + + if sys.platform == 'win32': + container_file_actual = container_file_actual.lower() + container_file_expected = container_file_expected.lower() + + assert args[0] == [ + 'docker', + 'image', + 'build', + '--ssh', + 'default', + '--build-arg', 'GRIZZLY_EXTRA=mq', + '--build-arg', 'GRIZZLY_INSTALL_TYPE=local', + '--build-arg', 'GRIZZLY_UID=1337', + '--build-arg', 'GRIZZLY_GID=2147483647', + '-f', + '-t', 'foobar:test-user', + test_context.as_posix(), + '--no-cache', + ] + assert container_file_actual == container_file_expected + + capsys.readouterr() + + actual_env = kwargs.get('env', None) + assert actual_env is not None + assert actual_env.get('DOCKER_BUILDKIT', None) == '1' + + image_name = 'grizzly-scenarios:test-user' + test_args = Namespace( + container_system='docker', + force_build=False, + local_install=False, + project_name=None, + registry='ghcr.io/biometria-se/', + no_progress=False, + verbose=False, + ) + + assert build(test_args) == 1 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + f'\nbuilt image {image_name}\n' + f'\n!! failed to tag image {image_name} -> ghcr.io/biometria-se/{image_name}\n' + ) + + assert run_command.call_count == 4 + + args, kwargs = run_command.call_args_list[-1] + assert args[0] == [ + 'docker', + 'image', + 'tag', + image_name, + f'ghcr.io/biometria-se/{image_name}', + ] + + actual_env = kwargs.get('env', None) + assert actual_env.get('DOCKER_BUILDKIT', None) == '1' + + test_args = Namespace( + container_system='docker', + force_build=True, + no_cache=True, + build=True, + registry='ghcr.io/biometria-se/', + project_name='foobar', + local_install=True, + no_progress=False, + verbose=False, + ) + + image_name = 'foobar:test-user' + assert build(test_args) == 2 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + f'\nbuilt image {image_name}\n' + f'tagged image {image_name} -> ghcr.io/biometria-se/{image_name}\n' + f'\n!! failed to push image ghcr.io/biometria-se/{image_name}\n' + ) + + assert run_command.call_count == 7 + + args, kwargs = run_command.call_args_list[-1] + assert args[0] == [ + 'docker', + 'image', + 'push', + f'ghcr.io/biometria-se/{image_name}', + ] + + actual_env = kwargs.get('env', None) + assert actual_env.get('DOCKER_BUILDKIT', None) == '1' + + assert build(test_args) == 0 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + f'\nbuilt image {image_name}\n' + f'tagged image {image_name} -> ghcr.io/biometria-se/{image_name}\n' + f'pushed image ghcr.io/biometria-se/{image_name}\n' + ) finally: - chdir(CWD) - rm_rf(test_context) diff --git a/tests/unit/distributed/test_clean.py b/tests/unit/distributed/test_clean.py index 3772d48..af33972 100644 --- a/tests/unit/distributed/test_clean.py +++ b/tests/unit/distributed/test_clean.py @@ -1,19 +1,23 @@ -from argparse import Namespace +from __future__ import annotations -from pytest_mock import MockerFixture +from argparse import Namespace +from typing import TYPE_CHECKING -from grizzly_cli.utils import RunCommandResult from grizzly_cli.distributed.clean import clean +from grizzly_cli.utils import RunCommandResult + +if TYPE_CHECKING: + from pytest_mock import MockerFixture -def test_clean(mocker: MockerFixture) -> None: - import grizzly_cli.distributed.clean - mocker.patch.object(grizzly_cli.distributed.clean, 'STATIC_CONTEXT', '/tmp/static-context') +def test_clean(mocker: MockerFixture) -> None: # noqa: PLR0915 + import grizzly_cli.distributed.clean # noqa: PLC0415 + mocker.patch.object(grizzly_cli.distributed.clean, 'STATIC_CONTEXT', '/srv/grizzly/static-context') mocker.patch.object(grizzly_cli.distributed.clean, 'PROJECT_NAME', 'grizzly-cli-test-project') arguments = Namespace(networks=True, images=True, project_name='foobar', container_system='docker', id=None) mocker.patch('grizzly_cli.distributed.clean.getuser', return_value='root') - mocker.patch('grizzly_cli.distributed.clean.get_terminal_size', return_value=(1024, 1024,)) + mocker.patch('grizzly_cli.distributed.clean.get_terminal_size', return_value=(1024, 1024)) run_command_spy = mocker.patch('grizzly_cli.distributed.clean.run_command', side_effect=[ RunCommandResult(return_code=0), @@ -33,7 +37,7 @@ def test_clean(mocker: MockerFixture) -> None: args, kwargs = run_command_spy.call_args_list[0] assert args[0] == [ 'docker', 'compose', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', '-p', 'foobar-root', 'rm', '-f', '-s', '-v', ] @@ -79,7 +83,7 @@ def test_clean(mocker: MockerFixture) -> None: args, kwargs = run_command_spy.call_args_list[0] assert args[0] == [ 'docker', 'compose', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', '-p', 'grizzly-cli-test-project-foobar-root', 'rm', '-f', '-s', '-v', ] @@ -108,7 +112,7 @@ def test_clean(mocker: MockerFixture) -> None: args, kwargs = run_command_spy.call_args_list[0] assert args[0] == [ 'docker', 'compose', - '-f', '/tmp/static-context/compose.yaml', + '-f', '/srv/grizzly/static-context/compose.yaml', '-p', 'grizzly-cli-test-project-root', 'rm', '-f', '-s', '-v', ] diff --git a/tests/unit/test___init__.py b/tests/unit/test___init__.py index 6b77360..0f0d7c5 100644 --- a/tests/unit/test___init__.py +++ b/tests/unit/test___init__.py @@ -1,42 +1,41 @@ -from os import chdir, environ, path, getcwd -from inspect import getfile -from importlib import reload -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture +from __future__ import annotations -from tests.helpers import rm_rf +from contextlib import suppress +from importlib import reload +from inspect import getfile +from os import environ +from pathlib import Path +from typing import TYPE_CHECKING +from tests.helpers import cwd, rm_rf -CWD = getcwd() +if TYPE_CHECKING: # pragma: no cover + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture def test___import__(tmp_path_factory: TempPathFactory, mocker: MockerFixture) -> None: test_context = tmp_path_factory.mktemp('test_context') - test_context_root = str(test_context) - - chdir(test_context_root) try: - environ['GRIZZLY_MOUNT_CONTEXT'] = '/var/tmp' + with cwd(test_context): + environ['GRIZZLY_MOUNT_CONTEXT'] = '/srv/grizzly' - import grizzly_cli - reload(grizzly_cli) - mocker.patch.object(grizzly_cli, '__version__', '1.2.3') + import grizzly_cli # noqa: PLC0415 + reload(grizzly_cli) + mocker.patch.object(grizzly_cli, '__version__', '1.2.3') - static_context = path.join(path.dirname(getfile(grizzly_cli)), 'static') + static_context = Path.joinpath(Path(getfile(grizzly_cli)).parent, 'static') - assert grizzly_cli.__version__ == '1.2.3' - assert grizzly_cli.EXECUTION_CONTEXT == test_context_root - assert grizzly_cli.MOUNT_CONTEXT == '/var/tmp' - assert grizzly_cli.STATIC_CONTEXT == static_context - assert grizzly_cli.PROJECT_NAME == path.basename(test_context_root) - assert len(grizzly_cli.SCENARIOS) == 0 + assert grizzly_cli.__version__ == '1.2.3' + assert test_context.as_posix() == grizzly_cli.EXECUTION_CONTEXT + assert grizzly_cli.MOUNT_CONTEXT == '/srv/grizzly' + assert static_context.as_posix() == grizzly_cli.STATIC_CONTEXT + assert test_context.name == grizzly_cli.PROJECT_NAME + assert len(grizzly_cli.SCENARIOS) == 0 finally: - chdir(CWD) - rm_rf(test_context_root) + rm_rf(test_context) - try: + with suppress(KeyError): del environ['GRIZZLY_MOUNT_CONTEXT'] - except: - pass diff --git a/tests/unit/test___main__.py b/tests/unit/test___main__.py index 7334eb5..42376c4 100644 --- a/tests/unit/test___main__.py +++ b/tests/unit/test___main__.py @@ -1,24 +1,25 @@ -import sys +from __future__ import annotations +import sys +from argparse import ArgumentParser as CoreArgumentParser +from argparse import Namespace from hashlib import sha1 -from typing import Dict, Optional, cast -from argparse import ArgumentParser as CoreArgumentParser, Namespace -from os import getcwd, environ, chdir, path +from os import environ +from pathlib import Path +from typing import TYPE_CHECKING, Optional, cast import pytest -from _pytest.capture import CaptureFixture -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture - -from grizzly_cli.__main__ import _create_parser, _parse_arguments, _inject_additional_arguments_from_metadata, main +from grizzly_cli.__main__ import _create_parser, _inject_additional_arguments_from_metadata, _parse_arguments, main +from tests.helpers import SOME, cwd, rm_rf -from tests.helpers import rm_rf +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture -CWD = getcwd() - -def test__create_parser() -> None: +def test__create_parser() -> None: # noqa: PLR0915 parser = _create_parser() assert parser.prog == 'grizzly-cli' @@ -37,9 +38,9 @@ def test__create_parser() -> None: subparser = parser._subparsers._group_actions[0] assert subparser is not None assert subparser.choices is not None - assert len(cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).keys()) == 5 + assert len(cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).keys()) == 5 - init_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('init', None) + init_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('init', None) assert init_parser is not None assert init_parser._subparsers is None assert getattr(init_parser, 'prog', None) == 'grizzly-cli init' @@ -50,7 +51,7 @@ def test__create_parser() -> None: '--grizzly-version', ]) - auth_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('auth', None) + auth_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('auth', None) assert auth_parser is not None assert auth_parser._subparsers is None assert getattr(auth_parser, 'prog', None) == 'grizzly-cli auth' @@ -58,7 +59,7 @@ def test__create_parser() -> None: '-h', '--help', ]) - keyvault_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('keyvault', None) + keyvault_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('keyvault', None) assert keyvault_parser is not None print(keyvault_parser._subparsers) assert keyvault_parser._subparsers is not None @@ -67,7 +68,7 @@ def test__create_parser() -> None: '--file', '-f', '-h', '--help', '--vault-name', ]) - local_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('local', None) + local_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('local', None) assert local_parser is not None assert local_parser._subparsers is not None assert getattr(local_parser, 'prog', None) == 'grizzly-cli local' @@ -78,9 +79,9 @@ def test__create_parser() -> None: local_subparser = local_parser._subparsers._group_actions[0] assert local_subparser is not None assert local_subparser.choices is not None - assert list(cast(Dict[str, Optional[CoreArgumentParser]], local_subparser.choices).keys()) == ['run'] + assert list(cast('dict[str, Optional[CoreArgumentParser]]', local_subparser.choices).keys()) == ['run'] - dist_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('dist', None) + dist_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('dist', None) assert dist_parser is not None assert dist_parser._subparsers is not None assert getattr(dist_parser, 'prog', None) == 'grizzly-cli dist' @@ -104,9 +105,9 @@ def test__create_parser() -> None: dist_subparser = dist_parser._subparsers._group_actions[0] assert dist_subparser is not None assert dist_subparser.choices is not None - assert list(cast(Dict[str, Optional[CoreArgumentParser]], dist_subparser.choices).keys()) == ['build', 'clean', 'run'] + assert list(cast('dict[str, Optional[CoreArgumentParser]]', dist_subparser.choices).keys()) == ['build', 'clean', 'run'] - dist_build_parser = cast(Dict[str, Optional[CoreArgumentParser]], dist_subparser.choices).get('build', None) + dist_build_parser = cast('dict[str, Optional[CoreArgumentParser]]', dist_subparser.choices).get('build', None) assert dist_build_parser is not None assert dist_build_parser._subparsers is None assert getattr(dist_build_parser, 'prog', None) == 'grizzly-cli dist build' @@ -119,22 +120,22 @@ def test__create_parser() -> None: '--verbose', ]) - dist_clean_parser = cast(Dict[str, Optional[CoreArgumentParser]], dist_subparser.choices).get('clean', None) + dist_clean_parser = cast('dict[str, Optional[CoreArgumentParser]]', dist_subparser.choices).get('clean', None) assert dist_clean_parser is not None assert dist_clean_parser._subparsers is None assert getattr(dist_clean_parser, 'prog', None) == 'grizzly-cli dist clean' assert sorted([option_string for action in dist_clean_parser._actions for option_string in action.option_strings]) == sorted([ '-h', '--help', '--no-images', - '--no-networks' + '--no-networks', ]) # grizzly-cli ... run - for tested_parser, parent in [(local_parser, 'local',), (dist_parser, 'dist',)]: + for tested_parser, parent in [(local_parser, 'local'), (dist_parser, 'dist')]: assert tested_parser._subparsers is not None assert len(tested_parser._subparsers._group_actions) == 1 subparser = tested_parser._subparsers._group_actions[0] - run_parser = cast(Dict[str, Optional[CoreArgumentParser]], subparser.choices).get('run', None) + run_parser = cast('dict[str, Optional[CoreArgumentParser]]', subparser.choices).get('run', None) assert run_parser is not None assert getattr(run_parser, 'prog', None) == f'grizzly-cli {parent} run' assert sorted([option_string for action in run_parser._actions for option_string in action.option_strings]) == sorted([ @@ -151,352 +152,348 @@ def test__create_parser() -> None: assert sorted([action.dest for action in run_parser._actions if len(action.option_strings) == 0]) == ['file'] -def test__parse_argument(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: +def test__parse_argument(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') (test_context / 'test.feature').write_text('Feature:') - test_context_root = str(test_context) - import sys + import sys # noqa: PLC0415 try: - mocker.patch('grizzly_cli.EXECUTION_CONTEXT', test_context_root) - chdir(test_context_root) - sys.argv = ['grizzly-cli'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - capture = capsys.readouterr() - assert capture.out == '' - assert 'usage: grizzly-cli' in capture.err - assert 'grizzly-cli: error: no command specified' in capture.err - - sys.argv = ['grizzly-cli', '--version'] - - mocker.patch('grizzly_cli.__main__.__version__', '0.0.0') - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == 'grizzly-cli (development)\n' - - sys.argv = ['grizzly-cli', '--version', 'foo'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - capture = capsys.readouterr() - err = capture.err.split('\n') - assert len(err) == 4 - assert err[0].startswith('usage: grizzly-cli') - assert 'init,local,dist,auth,keyvault' in err[1] - assert err[2] == ( - "grizzly-cli: error: argument --version: invalid choice: 'foo' (choose from 'all')" - ) or ( - "grizzly-cli: error: argument --version: invalid choice: 'foo' (choose from all)" - ) - assert err[3] == '' - assert capture.out == '' - - requirements_file = test_context / 'requirements.txt' - requirements_file.write_text('grizzly-loadtester==1.5.3\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli (development)\n' - '└── grizzly 1.5.3\n' - ' └── locust 2.2.1\n' - ) - - requirements_file.write_text('grizzly-loadtester[mq]==1.5.3\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli (development)\n' - '└── grizzly 1.5.3 ── extras: mq\n' - ' └── locust 2.2.1\n' - ) - - requirements_file.unlink() - requirements_file.write_text('grizzly-loadtester[mq,dev]==1.5.3\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli (development)\n' - '└── grizzly 1.5.3 ── extras: mq, dev\n' - ' └── locust 2.2.1\n' - ) - - def mocked_mkdtemp(prefix: Optional[str] = '') -> str: - tmp_dir = path.join(test_context, f'{prefix}test') - - return tmp_dir - - mocker.patch('grizzly_cli.utils.mkdtemp', mocked_mkdtemp) - mocker.patch('grizzly_cli.utils.subprocess.check_call', return_value=0) - mocker.patch('grizzly_cli.utils.subprocess.check_output', return_value='main\n') - - repo = 'git+https://git@github.com/biometria-se/grizzly.git@main#egg=grizzly-loadtester' - repo_suffix = sha1(repo.encode('utf-8')).hexdigest() - repo_dir = test_context / 'grizzly-cli-test' / f'grizzly-loadtester_{repo_suffix}' - repo_dir.mkdir(parents=True) - (repo_dir / 'pyproject.toml').touch() - (repo_dir / 'setup.cfg').write_text('name = grizzly-loadtester\nversion = 0.0.0\n') - (repo_dir / 'requirements.txt').write_text('locust==2.8.4 \\ \n') - - requirements_file.unlink() - requirements_file.write_text(f'{repo}\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli (development)\n' - '└── grizzly (development)\n' - ' └── locust 2.8.4\n' - ) - - repo = 'git+https://git@github.com/biometria-se/grizzly.git@main#egg=grizzly-loadtester[mq,dev]' - repo_suffix = sha1(repo.encode('utf-8')).hexdigest() - repo_dir = test_context / 'grizzly-cli-test' / f'grizzly-loadtester__mq_dev___{repo_suffix}' - repo_dir.mkdir(parents=True) - (repo_dir / 'pyproject.toml').touch() - (repo_dir / 'setup.cfg').write_text('name = grizzly-loadtester\nversion = 0.0.0\n') - (repo_dir / 'requirements.txt').write_text('locust==2.8.4 \\ \n') - - requirements_file.unlink() - requirements_file.write_text(f'{repo}\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli (development)\n' - '└── grizzly (development) ── extras: mq, dev\n' - ' └── locust 2.8.4\n' - ) - - requirements_file.unlink() - requirements_file.write_text('grizzly-loadtester==1.5.3\n') - - sys.argv = ['grizzly-cli', '--version', 'all'] - mocker.patch('grizzly_cli.__main__.__version__', '2.5.0') - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli 2.5.0\n' - '└── grizzly 1.5.3\n' - ' └── locust 2.2.1\n' - ) - - sys.argv = ['grizzly-cli', '--version', 'all'] - mocker.patch('grizzly_cli.__main__.__version__', '2.5.0') - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 0 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == ( - 'grizzly-cli 2.5.0\n' - '└── grizzly 1.5.3\n' - ' └── locust 2.2.1\n' - ) - - sys.argv = ['grizzly-cli', 'local'] - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - capture = capsys.readouterr() - assert capture.out == '' - assert 'grizzly-cli: error: no subcommand for local specified\n' == capture.err - - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - - mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=[None]) - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - capture = capsys.readouterr() - assert capture.out == '' - assert capture.err == 'grizzly-cli: error: cannot run distributed\n' - - mocker.patch('grizzly_cli.EXECUTION_CONTEXT', getcwd()) - mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) - mocker.patch('grizzly_cli.distributed.do_build', side_effect=[0, 4, 0]) - - sys.argv = ['grizzly-cli', 'dist', '--limit-nofile', '100', '--registry', 'ghcr.io/biometria-se', 'run', 'test.feature'] - (test_context / 'requirements.txt').write_text('grizzly-loadtester') - mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) - ask_yes_no = mocker.patch('grizzly_cli.__main__.ask_yes_no', autospec=True) - - arguments = _parse_arguments() - capture = capsys.readouterr() - assert arguments.limit_nofile == 100 - assert not arguments.yes - assert arguments.registry == 'ghcr.io/biometria-se/' - assert capture.out == '!! this will cause warning messages from locust later on\n' - assert capture.err == '' - assert ask_yes_no.call_count == 1 - args, _ = ask_yes_no.call_args_list[-1] - assert args[0] == 'are you sure you know what you are doing?' - - sys.argv = ['grizzly-cli', 'local', 'run', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=[None]) - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - - capture = capsys.readouterr() - assert capture.out == '' - assert capture.err == 'grizzly-cli: error: "behave" not found in PATH, needed when running local mode\n' - - # csv logging - sys.argv = ['grizzly-cli', 'local', 'run', '--csv-interval', '20', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - - capture = capsys.readouterr() - assert capture.out == '' - assert capture.err == 'grizzly-cli: error: --csv-interval can only be used in combination with --csv-prefix\n' - - sys.argv = ['grizzly-cli', 'dist', 'run', '--csv-flush-interval', '60', 'test.feature'] - mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) - - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 - - capture = capsys.readouterr() - assert capture.out == '' - assert capture.err == 'grizzly-cli: error: --csv-flush-interval can only be used in combination with --csv-prefix\n' - - sys.argv = ['grizzly-cli', 'local', 'run', '--csv-prefix', '--csv-interval', '20', '--csv-flush-interval', '60', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) - - parsed_args = _parse_arguments() - - assert getattr(parsed_args, 'csv_prefix', False) - assert getattr(parsed_args, 'csv_interval', None) == 20 - assert getattr(parsed_args, 'csv_flush_interval', None) == 60 - - sys.argv = ['grizzly-cli', 'local', 'run', '--csv-prefix', 'static csv prefix', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) - - parsed_args = _parse_arguments() - - assert getattr(parsed_args, 'csv_prefix', None) == 'static csv prefix' - assert getattr(parsed_args, 'csv_interval', None) is None - assert getattr(parsed_args, 'csv_flush_interval', None) is None - # // csv logging + mocker.patch('grizzly_cli.EXECUTION_CONTEXT', test_context.as_posix()) + with cwd(test_context): + sys.argv = ['grizzly-cli'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + capture = capsys.readouterr() + assert capture.out == '' + assert 'usage: grizzly-cli' in capture.err + assert 'grizzly-cli: error: no command specified' in capture.err + + sys.argv = ['grizzly-cli', '--version'] + + mocker.patch('grizzly_cli.__main__.__version__', '0.0.0') + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == 'grizzly-cli (development)\n' + + sys.argv = ['grizzly-cli', '--version', 'foo'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + capture = capsys.readouterr() + err = capture.err.split('\n') + assert len(err) == 4 + assert err[0].startswith('usage: grizzly-cli') + assert 'init,local,auth,dist,keyvault' in err[1] + assert err[2] == ( + "grizzly-cli: error: argument --version: invalid choice: 'foo' (choose from 'all')" + ) or ( + "grizzly-cli: error: argument --version: invalid choice: 'foo' (choose from all)" + ) + assert err[3] == '' + assert capture.out == '' + + requirements_file = test_context / 'requirements.txt' + requirements_file.write_text('grizzly-loadtester==1.5.3\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli (development)\n' + '└── grizzly 1.5.3\n' + ' └── locust 2.2.1\n' + ) + + requirements_file.write_text('grizzly-loadtester[mq]==1.5.3\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli (development)\n' + '└── grizzly 1.5.3 ── extras: mq\n' + ' └── locust 2.2.1\n' + ) + + requirements_file.unlink() + requirements_file.write_text('grizzly-loadtester[mq,dev]==1.5.3\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli (development)\n' + '└── grizzly 1.5.3 ── extras: mq, dev\n' + ' └── locust 2.2.1\n' + ) + + def mocked_mkdtemp(prefix: Optional[str] = '') -> str: + return Path.joinpath(test_context, f'{prefix}test').as_posix() + + mocker.patch('grizzly_cli.utils.mkdtemp', mocked_mkdtemp) + mocker.patch('grizzly_cli.utils.subprocess.check_call', return_value=0) + mocker.patch('grizzly_cli.utils.subprocess.check_output', return_value='main\n') + + repo = 'git+https://git@github.com/biometria-se/grizzly.git@main#egg=grizzly-loadtester' + repo_suffix = sha1(repo.encode('utf-8')).hexdigest() # noqa: S324 + repo_dir = test_context / 'grizzly-cli-test' / f'grizzly-loadtester_{repo_suffix}' + repo_dir.mkdir(parents=True) + (repo_dir / 'pyproject.toml').touch() + (repo_dir / 'setup.cfg').write_text('name = grizzly-loadtester\nversion = 0.0.0\n') + (repo_dir / 'requirements.txt').write_text('locust==2.8.4 \\ \n') + + requirements_file.unlink() + requirements_file.write_text(f'{repo}\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli (development)\n' + '└── grizzly (development)\n' + ' └── locust 2.8.4\n' + ) + + repo = 'git+https://git@github.com/biometria-se/grizzly.git@main#egg=grizzly-loadtester[mq,dev]' + repo_suffix = sha1(repo.encode('utf-8')).hexdigest() # noqa: S324 + repo_dir = test_context / 'grizzly-cli-test' / f'grizzly-loadtester__mq_dev___{repo_suffix}' + repo_dir.mkdir(parents=True) + (repo_dir / 'pyproject.toml').touch() + (repo_dir / 'setup.cfg').write_text('name = grizzly-loadtester\nversion = 0.0.0\n') + (repo_dir / 'requirements.txt').write_text('locust==2.8.4 \\ \n') + + requirements_file.unlink() + requirements_file.write_text(f'{repo}\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli (development)\n' + '└── grizzly (development) ── extras: mq, dev\n' + ' └── locust 2.8.4\n' + ) + + requirements_file.unlink() + requirements_file.write_text('grizzly-loadtester==1.5.3\n') + + sys.argv = ['grizzly-cli', '--version', 'all'] + mocker.patch('grizzly_cli.__main__.__version__', '2.5.0') + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli 2.5.0\n' + '└── grizzly 1.5.3\n' + ' └── locust 2.2.1\n' + ) + + sys.argv = ['grizzly-cli', '--version', 'all'] + mocker.patch('grizzly_cli.__main__.__version__', '2.5.0') + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 0 + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == ( + 'grizzly-cli 2.5.0\n' + '└── grizzly 1.5.3\n' + ' └── locust 2.2.1\n' + ) + + sys.argv = ['grizzly-cli', 'local'] + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: no subcommand for local specified\n' + + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + + mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=[None]) + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: cannot run distributed\n' + + mocker.patch('grizzly_cli.EXECUTION_CONTEXT', Path.cwd()) + mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) + mocker.patch('grizzly_cli.distributed.do_build', side_effect=[0, 4, 0]) + + sys.argv = ['grizzly-cli', 'dist', '--limit-nofile', '100', '--registry', 'ghcr.io/biometria-se', 'run', 'test.feature'] + (test_context / 'requirements.txt').write_text('grizzly-loadtester') + mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) + ask_yes_no = mocker.patch('grizzly_cli.__main__.ask_yes_no', autospec=True) + + arguments = _parse_arguments() + capture = capsys.readouterr() + assert arguments.limit_nofile == 100 + assert not arguments.yes + assert arguments.registry == 'ghcr.io/biometria-se/' + assert capture.out == '!! this will cause warning messages from locust later on\n' + assert capture.err == '' + assert ask_yes_no.call_count == 1 + args, _ = ask_yes_no.call_args_list[-1] + assert args[0] == 'are you sure you know what you are doing?' + + sys.argv = ['grizzly-cli', 'local', 'run', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=[None]) + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: "behave" not found in PATH, needed when running local mode\n' + + # csv logging + sys.argv = ['grizzly-cli', 'local', 'run', '--csv-interval', '20', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: --csv-interval can only be used in combination with --csv-prefix\n' + + sys.argv = ['grizzly-cli', 'dist', 'run', '--csv-flush-interval', '60', 'test.feature'] + mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker']) + + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 + + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: --csv-flush-interval can only be used in combination with --csv-prefix\n' + + sys.argv = ['grizzly-cli', 'local', 'run', '--csv-prefix', '--csv-interval', '20', '--csv-flush-interval', '60', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) + + parsed_args = _parse_arguments() + + assert getattr(parsed_args, 'csv_prefix', False) + assert getattr(parsed_args, 'csv_interval', None) == 20 + assert getattr(parsed_args, 'csv_flush_interval', None) == 60 + + sys.argv = ['grizzly-cli', 'local', 'run', '--csv-prefix', 'static csv prefix', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) + + parsed_args = _parse_arguments() + + assert getattr(parsed_args, 'csv_prefix', None) == 'static csv prefix' + assert getattr(parsed_args, 'csv_interval', None) is None + assert getattr(parsed_args, 'csv_flush_interval', None) is None + # // csv logging - # -T/--testdata-variable - sys.argv = ['grizzly-cli', 'local', 'run', '-T', 'variable', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) + # -T/--testdata-variable + sys.argv = ['grizzly-cli', 'local', 'run', '-T', 'variable', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) - with pytest.raises(SystemExit) as se: - _parse_arguments() - assert se.type == SystemExit - assert se.value.code == 2 + with pytest.raises(SystemExit) as se: + _parse_arguments() + assert se.type is SystemExit + assert se.value.code == 2 - capture = capsys.readouterr() - assert capture.out == '' - assert capture.err == 'grizzly-cli: error: -T/--testdata-variable needs to be in the format NAME=VALUE\n' + capture = capsys.readouterr() + assert capture.out == '' + assert capture.err == 'grizzly-cli: error: -T/--testdata-variable needs to be in the format NAME=VALUE\n' - sys.argv = ['grizzly-cli', 'local', 'run', '-T', 'key=value', 'test.feature'] - mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) + sys.argv = ['grizzly-cli', 'local', 'run', '-T', 'key=value', 'test.feature'] + mocker.patch('grizzly_cli.__main__.which', side_effect=['behave']) - assert environ.get('TESTDATA_VARIABLE_key', None) is None - - arguments = _parse_arguments() - assert arguments.command == 'local' - assert arguments.subcommand == 'run' - assert arguments.file == 'test.feature' + assert environ.get('TESTDATA_VARIABLE_key', None) is None # noqa: SIM112 + + arguments = _parse_arguments() + assert arguments.command == 'local' + assert arguments.subcommand == 'run' + assert arguments.file == 'test.feature' - assert environ.get('TESTDATA_VARIABLE_key', None) == 'value' - # // -T/--testdata-variable + assert environ.get('TESTDATA_VARIABLE_key', None) == 'value' # noqa: SIM112 + # // -T/--testdata-variable - mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker'] * 3) + mocker.patch('grizzly_cli.__main__.get_distributed_system', side_effect=['docker'] * 3) - sys.argv = ['grizzly-cli', 'dist', 'build'] - arguments = _parse_arguments() + sys.argv = ['grizzly-cli', 'dist', 'build'] + arguments = _parse_arguments() - assert not arguments.no_cache - assert not arguments.force_build - assert arguments.build - assert arguments.registry is None + assert not arguments.no_cache + assert not arguments.force_build + assert arguments.build + assert arguments.registry is None - sys.argv = ['grizzly-cli', 'dist', 'build', '--no-cache', '--registry', 'registry.example.com/biometria-se'] - arguments = _parse_arguments() + sys.argv = ['grizzly-cli', 'dist', 'build', '--no-cache', '--registry', 'registry.example.com/biometria-se'] + arguments = _parse_arguments() - assert arguments.no_cache - assert arguments.force_build - assert not arguments.build - assert arguments.registry == 'registry.example.com/biometria-se/' + assert arguments.no_cache + assert arguments.force_build + assert not arguments.build + assert arguments.registry == 'registry.example.com/biometria-se/' - sys.argv = ['grizzly-cli', 'init', 'test-project'] - arguments = _parse_arguments() + sys.argv = ['grizzly-cli', 'init', 'test-project'] + arguments = _parse_arguments() - assert arguments.project == 'test-project' - assert getattr(arguments, 'subcommand', None) is None + assert arguments.project == 'test-project' + assert getattr(arguments, 'subcommand', None) is None finally: - chdir(CWD) - rm_rf(test_context_root) + rm_rf(test_context) def test__inject_additional_arguments_from_metadata(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: MockerFixture) -> None: @@ -507,69 +504,65 @@ def test__inject_additional_arguments_from_metadata(tmp_path_factory: TempPathFa mocker.patch('grizzly_cli.__main__.get_distributed_system', return_value='docker') try: - chdir(test_context) - test_feature_file.write_text('# grizzly-cli run --verbose\nFeature:\n') - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + with cwd(test_context): + test_feature_file.write_text('# grizzly-cli run --verbose\nFeature:\n') + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - orig_args = _parse_arguments() - assert not orig_args.verbose + orig_args = _parse_arguments() + assert not orig_args.verbose - args = _inject_additional_arguments_from_metadata(orig_args) - capture = capsys.readouterr() + args = _inject_additional_arguments_from_metadata(orig_args) + capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '' - assert args.verbose + assert capture.err == capture.out == '' + assert args.verbose - test_feature_file.write_text('# grizzly-cli local --hello\nFeature:\n') - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + test_feature_file.write_text('# grizzly-cli local --hello\nFeature:\n') + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - orig_args = _parse_arguments() - args = _inject_additional_arguments_from_metadata(orig_args) + orig_args = _parse_arguments() + args = _inject_additional_arguments_from_metadata(orig_args) - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '?? ignoring local --hello\n' + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == '?? ignoring local --hello\n' - test_feature_file.write_text('Feature:\n') - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + test_feature_file.write_text('Feature:\n') + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - orig_args = _parse_arguments() - args = _inject_additional_arguments_from_metadata(orig_args) + orig_args = _parse_arguments() + args = _inject_additional_arguments_from_metadata(orig_args) - assert args is orig_args + assert args is orig_args - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '' + capture = capsys.readouterr() + assert capture.err == capture.out == '' - test_feature_file.write_text('# grizzly-cli --health-timeout 100\nFeature:\n') - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + test_feature_file.write_text('# grizzly-cli --health-timeout 100\nFeature:\n') + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - orig_args = _parse_arguments() - args = _inject_additional_arguments_from_metadata(orig_args) + orig_args = _parse_arguments() + args = _inject_additional_arguments_from_metadata(orig_args) - assert args.health_timeout == orig_args.health_timeout + assert args.health_timeout == orig_args.health_timeout - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '?? ignoring --health-timeout 100\n' + capture = capsys.readouterr() + assert capture.err == '' + assert capture.out == '?? ignoring --health-timeout 100\n' - test_feature_file.write_text('# grizzly-cli dist --health-timeout 100 --health-retries 101\nFeature:\n# grizzly-cli dist --health-interval 5\n') - sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] + test_feature_file.write_text('# grizzly-cli dist --health-timeout 100 --health-retries 101\nFeature:\n# grizzly-cli dist --health-interval 5\n') + sys.argv = ['grizzly-cli', 'dist', 'run', 'test.feature'] - orig_args = _parse_arguments() - args = _inject_additional_arguments_from_metadata(orig_args) + orig_args = _parse_arguments() + args = _inject_additional_arguments_from_metadata(orig_args) - assert args.health_timeout == 100 - assert args.health_retries == 101 - assert args.health_interval == 5 + assert args.health_timeout == 100 + assert args.health_retries == 101 + assert args.health_interval == 5 - capture = capsys.readouterr() - assert capture.err == '' - assert capture.out == '' + capture = capsys.readouterr() + assert capture.err == capture.out == '' finally: - chdir(CWD) rm_rf(test_context) @@ -589,25 +582,28 @@ def test_main(mocker: MockerFixture, capsys: CaptureFixture) -> None: KeyboardInterrupt, ValueError('hello there'), Namespace(command='dist', file='test.feature'), - ],) + ]) assert main() == 0 - assert local_mock.call_count == 1 - assert dist_mock.call_count == 0 - assert init_mock.call_count == 0 - assert inject_additional_arguments_from_metadata_mock.call_count == 0 + local_mock.assert_called_once_with(SOME(Namespace, command='local')) + local_mock.reset_mock() + dist_mock.assert_not_called() + init_mock.assert_not_called() + inject_additional_arguments_from_metadata_mock.assert_not_called() assert main() == 1337 - assert local_mock.call_count == 1 - assert dist_mock.call_count == 1 - assert init_mock.call_count == 0 - assert inject_additional_arguments_from_metadata_mock.call_count == 0 + local_mock.assert_not_called() + dist_mock.assert_called_once_with(SOME(Namespace, command='dist')) + dist_mock.reset_mock() + init_mock.assert_not_called() + inject_additional_arguments_from_metadata_mock.assert_not_called() assert main() == 7331 - assert local_mock.call_count == 1 - assert dist_mock.call_count == 1 - assert init_mock.call_count == 1 - assert inject_additional_arguments_from_metadata_mock.call_count == 0 + local_mock.assert_not_called() + dist_mock.assert_not_called() + init_mock.assert_called_once_with(SOME(Namespace, command='init')) + init_mock.reset_mock() + inject_additional_arguments_from_metadata_mock.assert_not_called() assert main() == 1 @@ -629,9 +625,8 @@ def test_main(mocker: MockerFixture, capsys: CaptureFixture) -> None: assert main() == 1373 capture = capsys.readouterr() - print(capture.err) - print(capture.out) - assert local_mock.call_count == 1 - assert dist_mock.call_count == 2 - assert init_mock.call_count == 1 - assert inject_additional_arguments_from_metadata_mock.call_count == 1 + local_mock.assert_not_called() + dist_mock.assert_called_once_with(SOME(Namespace, command='dist', file='test.feature')) + dist_mock.reset_mock() + init_mock.assert_not_called() + inject_additional_arguments_from_metadata_mock.assert_called_once_with(SOME(Namespace, command='dist', file='test.feature')) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d14deb4..23b12d6 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,35 +1,37 @@ -import sys +from __future__ import annotations +import sys +from contextlib import suppress from os import environ +from typing import TYPE_CHECKING import pytest -from _pytest.tmpdir import TempPathFactory -from _pytest.capture import CaptureFixture -from pytest_mock import MockerFixture - from grizzly_cli.__main__ import _parse_arguments from grizzly_cli.auth import auth +if TYPE_CHECKING: # pragma: no cover + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture + def test_auth_env(capsys: CaptureFixture, mocker: MockerFixture) -> None: try: sys.argv = ['grizzly-cli', 'auth'] arguments = _parse_arguments() - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='environment variable OTP_SECRET is not set'): auth(arguments) - assert str(ve.value) == 'environment variable OTP_SECRET is not set' capsys.readouterr() - environ['OTP_SECRET'] = 'f00bar=' + environ['OTP_SECRET'] = 'f00bar=' # noqa: S105 - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='unable to generate TOTP code: Non-base32 digit found'): auth(arguments) - assert str(ve.value) == 'unable to generate TOTP code: Non-base32 digit found' - environ['OTP_SECRET'] = 'asdfasdf' + environ['OTP_SECRET'] = 'asdfasdf' # noqa: S105 mocker.patch('grizzly_cli.auth.TOTP.now', return_value=111111) assert auth(arguments) == 0 @@ -39,10 +41,8 @@ def test_auth_env(capsys: CaptureFixture, mocker: MockerFixture) -> None: assert capture.err == '' assert capture.out == '111111\n' finally: - try: + with suppress(KeyError): del environ['OTP_SECRET'] - except: - pass def test_auth_stdin(capsys: CaptureFixture, mocker: MockerFixture) -> None: @@ -51,21 +51,18 @@ def test_auth_stdin(capsys: CaptureFixture, mocker: MockerFixture) -> None: mocker.patch('sys.stdin.read', return_value=None) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='OTP secret could not be read from stdin'): auth(arguments) - assert str(ve.value) == 'OTP secret could not be read from stdin' mocker.patch('sys.stdin.read', return_value=' ') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='OTP secret could not be read from stdin'): auth(arguments) - assert str(ve.value) == 'OTP secret could not be read from stdin' mocker.patch('sys.stdin.read', return_value='f00bar=') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='unable to generate TOTP code: Non-base32 digit found'): auth(arguments) - assert str(ve.value) == 'unable to generate TOTP code: Non-base32 digit found' capsys.readouterr() @@ -88,33 +85,28 @@ def test_auth_file(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_facto sys.argv = ['grizzly-cli', 'auth', str(file)] arguments = _parse_arguments() - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match=f'file {file.as_posix()} does not exist'): auth(arguments) - assert str(ve.value) == f'file {file} does not exist' file.write_text(' ') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match=f'file {file.as_posix()} does not seem to contain a single line with a valid OTP secret'): auth(arguments) - assert str(ve.value) == f'file {file} does not seem to contain a single line with a valid OTP secret' file.write_text('aasdf\nasdfasdf\n') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match=f'file {file.as_posix()} does not seem to contain a single line with a valid OTP secret'): auth(arguments) - assert str(ve.value) == f'file {file} does not seem to contain a single line with a valid OTP secret' file.write_text('hello world\n') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match=f'file {file.as_posix()} does not seem to contain a single line with a valid OTP secret'): auth(arguments) - assert str(ve.value) == f'file {file} does not seem to contain a single line with a valid OTP secret' file.write_text('f00bar=\n') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='unable to generate TOTP code: Non-base32 digit found'): auth(arguments) - assert str(ve.value) == 'unable to generate TOTP code: Non-base32 digit found' file.write_text('asdfasdf') mocker.patch('grizzly_cli.auth.TOTP.now', return_value=333333) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 52d28d6..68158b3 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -1,14 +1,17 @@ -import sys +from __future__ import annotations -from _pytest.tmpdir import TempPathFactory -from _pytest.capture import CaptureFixture -from pytest_mock import MockerFixture +import sys +from typing import TYPE_CHECKING -from grizzly_cli.init import tree, init from grizzly_cli.__main__ import _parse_arguments - +from grizzly_cli.init import init, tree from tests.helpers import rm_rf +if TYPE_CHECKING: # pragma: no cover + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture + def test_tree(tmp_path_factory: TempPathFactory) -> None: test_context = tmp_path_factory.mktemp('test_context') @@ -26,7 +29,7 @@ def test_tree(tmp_path_factory: TempPathFactory) -> None: (test_context / 'root.yaml').touch() try: - assert '\n'.join([line for line in tree(test_context)]) == '''├── a + assert '\n'.join(list(tree(test_context))) == """├── a │ ├── b │ │ ├── c │ │ │ ├── file-c1.txt @@ -34,12 +37,12 @@ def test_tree(tmp_path_factory: TempPathFactory) -> None: │ │ └── file-b1.txt │ ├── file-a1.txt │ └── file-a2.txt -└── root.yaml''' +└── root.yaml""" finally: rm_rf(test_context) -def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: MockerFixture) -> None: +def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: MockerFixture) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') test_existing = test_context / 'foobar' @@ -67,13 +70,13 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: capture = capsys.readouterr() assert capture.err == '' - assert capture.out == f'''oops, looks like you are already in a grizzly project directory + assert capture.out == f"""oops, looks like you are already in a grizzly project directory {test_existing} ├── environments ├── features └── requirements.txt -''' +""" rm_rf(test_existing) @@ -84,7 +87,7 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: assert question_mock.call_count == 1 args, _ = question_mock.call_args_list[-1] - assert args[0] == '''the following structure will be created: + assert args[0] == """the following structure will be created: foobar ├── environments @@ -97,32 +100,32 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: │ └── requests └── requirements.txt -do you want to create grizzly project "foobar"?''' +do you want to create grizzly project "foobar"?""" capture = capsys.readouterr() assert capture.err == '' - assert capture.out == '''successfully created project "foobar", with the following options: + assert capture.out == """successfully created project "foobar", with the following options: • without IBM MQ support • latest grizzly version -''' +""" template_root = test_context / 'foobar' assert template_root.is_dir() assert (template_root / 'environments').is_dir() environments_file = template_root / 'environments' / 'foobar.yaml' assert environments_file.is_file() - assert environments_file.read_text() == '''configuration: + assert environments_file.read_text() == """configuration: template: host: https://localhost -''' +""" assert (template_root / 'features').is_dir() feature_file = template_root / 'features' / 'foobar.feature' assert feature_file.is_file() - assert feature_file.read_text() == '''Feature: Template feature file + assert feature_file.read_text() == """Feature: Template feature file Scenario: Template scenario Given a user of type "RestApi" with weight "1" load testing "$conf::template.host" -''' +""" environment_file = template_root / 'features' / 'environment.py' assert environment_file.is_file() @@ -140,8 +143,8 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: assert requirements_file.is_file() assert requirements_file.read_text() == 'grizzly-loadtester\n' - created_structure = '\n'.join([line for line in tree(template_root)]) - assert created_structure == '''├── environments + created_structure = '\n'.join(list(tree(template_root))) + assert created_structure == """├── environments │ └── foobar.yaml ├── features │ ├── environment.py @@ -149,7 +152,7 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: │ ├── requests │ └── steps │ └── steps.py -└── requirements.txt''' +└── requirements.txt""" rm_rf(template_root) @@ -162,10 +165,10 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: capture = capsys.readouterr() assert capture.err == '' - assert capture.out == '''successfully created project "foobar", with the following options: + assert capture.out == """successfully created project "foobar", with the following options: • with IBM MQ support • latest grizzly version -''' +""" requirements_file = template_root / 'requirements.txt' assert requirements_file.is_file() assert requirements_file.read_text() == 'grizzly-loadtester[mq]\n' @@ -179,10 +182,10 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: capture = capsys.readouterr() assert capture.err == '' - assert capture.out == '''successfully created project "foobar", with the following options: + assert capture.out == """successfully created project "foobar", with the following options: • without IBM MQ support • pinned to grizzly version 1.2.4 -''' +""" requirements_file = template_root / 'requirements.txt' assert requirements_file.is_file() assert requirements_file.read_text() == 'grizzly-loadtester==1.2.4\n' @@ -190,10 +193,10 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: assert (template_root / 'features').is_dir() feature_file = template_root / 'features' / 'foobar.feature' assert feature_file.is_file() - assert feature_file.read_text() == '''Feature: Template feature file + assert feature_file.read_text() == """Feature: Template feature file Scenario: Template scenario Given a user of type "RestApi" with weight "1" load testing "$conf::template.host" -''' +""" environment_file = template_root / 'features' / 'environment.py' assert environment_file.is_file() @@ -208,10 +211,10 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: capture = capsys.readouterr() assert capture.err == '' - assert capture.out == '''successfully created project "foobar", with the following options: + assert capture.out == """successfully created project "foobar", with the following options: • with IBM MQ support • pinned to grizzly version 1.5.0 -''' +""" requirements_file = template_root / 'requirements.txt' assert requirements_file.is_file() assert requirements_file.read_text() == 'grizzly-loadtester[mq]==1.5.0\n' @@ -225,7 +228,7 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: capture = capsys.readouterr() assert capture.err == '' - assert capture.out == '''the following structure will be created: + assert capture.out == """the following structure will be created: foobar ├── environments @@ -241,7 +244,7 @@ def test_init(tmp_path_factory: TempPathFactory, capsys: CaptureFixture, mocker: successfully created project "foobar", with the following options: • without IBM MQ support • latest grizzly version -''' +""" requirements_file = template_root / 'requirements.txt' assert requirements_file.is_file() assert requirements_file.read_text() == 'grizzly-loadtester\n' diff --git a/tests/unit/test_keyvault.py b/tests/unit/test_keyvault.py index 8aaa5cc..39bea38 100644 --- a/tests/unit/test_keyvault.py +++ b/tests/unit/test_keyvault.py @@ -1,22 +1,26 @@ from __future__ import annotations -from _pytest.tmpdir import TempPathFactory -from pytest_mock.plugin import MockerFixture +from typing import TYPE_CHECKING + from grizzly_cli.keyvault import ( - _keyvault_normalize, - _should_export, - _determine_environment, + COMMON_FALSE_POSITIVES, + KEYWORDS, + KeyvaultSecretHolder, _build_key_name, + _determine_environment, _dict_to_yaml, _extract_metadata, - encode_mq_certificate, + _keyvault_normalize, + _should_export, encode_file, - KeyvaultSecretHolder, - KEYWORDS, - COMMON_FALSE_POSITIVES, + encode_mq_certificate, ) from tests.helpers import SOME, rm_rf +if TYPE_CHECKING: # pragma: no cover + from _pytest.tmpdir import TempPathFactory + from pytest_mock.plugin import MockerFixture + def test__keyvault_normalize() -> None: assert _keyvault_normalize('fo0b4R') == 'fo0b4R' @@ -95,7 +99,7 @@ def test__dict_to_yaml(tmp_path_factory: TempPathFactory) -> None: content: dict = { 'foo': { - 'bar': 'hello world' + 'bar': 'hello world', }, 'hello': 'world', 'test': { @@ -145,7 +149,7 @@ def test__extract_metadata(tmp_path_factory: TempPathFactory) -> None: 'env': 'test', 'foo': { 'bar': 'hello world', - } + }, }, } @@ -165,7 +169,7 @@ def test__extract_metadata(tmp_path_factory: TempPathFactory) -> None: 'configuration': { 'foo': { 'bar': 'hello world', - } + }, }, } diff --git a/tests/unit/test_local.py b/tests/unit/test_local.py index d48626f..fd97361 100644 --- a/tests/unit/test_local.py +++ b/tests/unit/test_local.py @@ -1,17 +1,18 @@ +from __future__ import annotations -from os import getcwd, environ from argparse import ArgumentParser, Namespace +from contextlib import suppress +from os import environ +from typing import TYPE_CHECKING import pytest -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture - +from grizzly_cli.local import create_parser, local, local_run from grizzly_cli.utils import RunCommandResult, rm_rf -from grizzly_cli.local import create_parser, local_run, local - -CWD = getcwd() +if TYPE_CHECKING: + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture def test_local(mocker: MockerFixture) -> None: @@ -26,9 +27,8 @@ def test_local(mocker: MockerFixture) -> None: assert args[1] is local_run arguments = Namespace(subcommand='foo') - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='unknown subcommand foo'): local(arguments) - assert 'unknown subcommand foo' == str(ve.value) def test_local_run(mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: @@ -49,7 +49,7 @@ def test_local_run(mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> 'local', 'run', f'{test_context}/test.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert local_run( arguments, @@ -101,7 +101,5 @@ def test_local_run(mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> assert environ.get('GRIZZLY_TEST_VAR', None) == 'True' finally: rm_rf(test_context) - try: + with suppress(KeyError): del environ['GRIZZLY_TEST_VAR'] - except: - pass diff --git a/tests/unit/test_run.py b/tests/unit/test_run.py index bb1b861..6038c0f 100644 --- a/tests/unit/test_run.py +++ b/tests/unit/test_run.py @@ -1,24 +1,27 @@ +from __future__ import annotations + import logging -from os import path from argparse import ArgumentParser from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING import pytest -from _pytest.capture import CaptureFixture -from _pytest.logging import LogCaptureFixture -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture from jinja2 import Environment -from grizzly_cli.run import run, create_parser +from grizzly_cli.run import create_parser, run from grizzly_cli.utils import setup_logging from grizzly_cli.utils.configuration import ScenarioTag - from tests.helpers import CaseInsensitive, rm_rf +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from _pytest.logging import LogCaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture -def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: + +def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: # noqa: PLR0915 setup_logging() original_tmp_path = tmp_path_factory._basetemp @@ -40,8 +43,8 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te create_parser(sub_parsers, parent='local') try: - mocker.patch('grizzly_cli.run.grizzly_cli.EXECUTION_CONTEXT', str(execution_context)) - mocker.patch('grizzly_cli.run.grizzly_cli.MOUNT_CONTEXT', str(mount_context)) + mocker.patch('grizzly_cli.run.grizzly_cli.EXECUTION_CONTEXT', execution_context.as_posix()) + mocker.patch('grizzly_cli.run.grizzly_cli.MOUNT_CONTEXT', mount_context.as_posix()) mocker.patch('grizzly_cli.run.get_hostname', return_value='localhost') mocker.patch('grizzly_cli.run.find_variable_names_in_questions', side_effect=[['foo', 'bar'], [], [], [], [], [], [], []]) mocker.patch('grizzly_cli.run.find_metadata_notices', side_effect=[[], ['is the event log cleared?'], ['hello world', 'foo bar'], [], [], [], [], []]) @@ -51,41 +54,41 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te local_mock = mocker.MagicMock(return_value=0) get_input_mock = mocker.patch('grizzly_cli.run.get_input', side_effect=['bar', 'foo']) - setattr(getattr(run, '__wrapped__'), '__value__', str(execution_context)) + setattr(getattr(run, '__wrapped__'), '__value__', execution_context.as_posix()) # noqa: B009, B010 arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', - f'{execution_context}/features/test.feature', - '--verbose' + '-e', f'{execution_context.as_posix()}/configuration.yaml', + f'{execution_context.as_posix()}/features/test.feature', + '--verbose', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, distributed_mock) == 0 capture = capsys.readouterr() assert capture.out == '' - assert capture.err == '''feature file requires values for 2 variables + assert capture.err == """feature file requires values for 2 variables the following values was provided: foo = bar bar = foo -''' +""" local_mock.assert_not_called() distributed_mock.assert_called_once_with( arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), 'TESTDATA_VARIABLE_foo': 'bar', 'TESTDATA_VARIABLE_bar': 'foo', }, { 'master': [], 'worker': [], 'common': ['--verbose', '--no-logcapture', '--no-capture', '--no-capture-stderr'], - } + }, ) distributed_mock.reset_mock() @@ -110,10 +113,10 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', - f'{execution_context}/features/test.feature', + '-e', f'{execution_context.as_posix()}/configuration.yaml', + f'{execution_context.as_posix()}/features/test.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -124,14 +127,14 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), }, { 'master': [], 'worker': [], 'common': [], - } + }, ) local_mock.reset_mock() @@ -145,11 +148,11 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te # with --yes, notices should only be printed, and not needed to be confirmed via ask_yes_no arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -159,14 +162,14 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), }, { 'master': [], 'worker': [], 'common': [], - } + }, ) local_mock.reset_mock() @@ -179,12 +182,12 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te # no `csv_prefix` nothing should be added arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', '--csv-interval', '20', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -192,14 +195,14 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), }, { 'master': [], 'worker': [], 'common': [], - } + }, ) local_mock.reset_mock() distributed_mock.assert_not_called() @@ -207,13 +210,13 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te # static csv-prefix arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', '--csv-interval', '20', '--csv-prefix', 'test test', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -222,33 +225,33 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), }, { 'master': [], 'worker': [], 'common': ['-Dcsv-prefix="test test"', '-Dcsv-interval=20'], - } + }, ) local_mock.reset_mock() # dynamic csv-prefix datetime_mock = mocker.patch( 'grizzly_cli.run.datetime', - side_effect=lambda *args, **kwargs: datetime(*args, **kwargs) + side_effect=lambda *args, **kwargs: datetime(*args, **kwargs), # noqa: DTZ001 ) - datetime_mock.now.return_value = datetime(2022, 12, 6, 13, 1, 13) + datetime_mock.now.return_value = datetime(2022, 12, 6, 13, 1, 13) # noqa: DTZ001 arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', '--csv-prefix', '--csv-interval', '20', '--csv-flush-interval', '60', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, distributed_mock) == 0 @@ -257,29 +260,29 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), }, { 'master': [], 'worker': [], 'common': ['-Dcsv-prefix="this_feature_is_testing_something_20221206T130113"', '-Dcsv-interval=20', '-Dcsv-flush-interval=60'], - } + }, ) distributed_mock.reset_mock() - setattr(arguments, 'csv_prefix', None) - setattr(arguments, 'csv_flush_interval', None) + arguments.csv_prefix = None + arguments.csv_flush_interval = None # --log-dir arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', '--log-dir', 'foobar', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, distributed_mock) == 0 @@ -288,15 +291,15 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), 'GRIZZLY_LOG_DIR': 'foobar', }, { 'master': [], 'worker': [], 'common': [], - } + }, ) distributed_mock.reset_mock() @@ -305,13 +308,13 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te # --dry-run arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', '--log-dir', 'foobar', - f'{execution_context}/features/test.feature', - '--dry-run' + f'{execution_context.as_posix()}/features/test.feature', + '--dry-run', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, distributed_mock) == 0 @@ -320,16 +323,16 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te arguments, { 'GRIZZLY_CLI_HOST': 'localhost', - 'GRIZZLY_EXECUTION_CONTEXT': str(execution_context), - 'GRIZZLY_MOUNT_CONTEXT': str(mount_context), - 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(path.join(execution_context, 'configuration.lock.yaml')), + 'GRIZZLY_EXECUTION_CONTEXT': execution_context.as_posix(), + 'GRIZZLY_MOUNT_CONTEXT': mount_context.as_posix(), + 'GRIZZLY_CONFIGURATION_FILE': CaseInsensitive(Path.joinpath(execution_context, 'configuration.lock.yaml').as_posix()), 'GRIZZLY_LOG_DIR': 'foobar', 'GRIZZLY_DRY_RUN': 'true', }, { 'master': [], 'worker': [], 'common': [], - } + }, ) distributed_mock.reset_mock() @@ -338,12 +341,12 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te # --dump arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', + f'{execution_context.as_posix()}/features/test.feature', '--dump', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, distributed_mock) == 0 @@ -359,7 +362,7 @@ def test_run(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: Te rm_rf(test_context) -def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: +def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: # noqa: PLR0915 setup_logging() original_tmp_path = tmp_path_factory._basetemp @@ -381,8 +384,8 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor create_parser(sub_parsers, parent='local') try: - mocker.patch('grizzly_cli.run.grizzly_cli.EXECUTION_CONTEXT', str(execution_context)) - mocker.patch('grizzly_cli.run.grizzly_cli.MOUNT_CONTEXT', str(mount_context)) + mocker.patch('grizzly_cli.run.grizzly_cli.EXECUTION_CONTEXT', execution_context.as_posix()) + mocker.patch('grizzly_cli.run.grizzly_cli.MOUNT_CONTEXT', mount_context.as_posix()) mocker.patch('grizzly_cli.run.get_hostname', return_value='localhost') mocker.patch('grizzly_cli.run.find_variable_names_in_questions', side_effect=[['foo', 'bar'], [], [], [], [], [], [], []]) mocker.patch('grizzly_cli.run.find_metadata_notices', side_effect=[[], ['is the event log cleared?'], ['hello world', 'foo bar'], [], [], [], [], []]) @@ -390,7 +393,7 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor distributed_mock = mocker.MagicMock(return_value=0) local_mock = mocker.MagicMock(return_value=0) - setattr(getattr(run, '__wrapped__'), '__value__', str(execution_context)) + setattr(getattr(run, '__wrapped__'), '__value__', execution_context.as_posix()) # noqa: B009, B010 # --dump output.feature feature_file.write_text("""Feature: a feature @@ -438,9 +441,9 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor '-e', f'{execution_context}/configuration.yaml', '--yes', f'{execution_context}/features/test.feature', - '--dump', f'{execution_context}/output.feature' + '--dump', f'{execution_context}/output.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -517,12 +520,12 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', - '--dump', f'{execution_context}/output.feature' + f'{execution_context.as_posix()}/features/test.feature', + '--dump', f'{execution_context.as_posix()}/output.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -600,22 +603,22 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', - '--dump', f'{execution_context}/output.feature' + f'{execution_context.as_posix()}/features/test.feature', + '--dump', f'{execution_context.as_posix()}/output.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError) as ve: # noqa: PT011 run(arguments, local_mock) - assert str(ve.value) == '''the following variables has been declared in scenario tag but not used in ../second.feature#second: + assert str(ve.value) == """the following variables has been declared in scenario tag but not used in ../second.feature#second: foo the following variables was used in ../second.feature#second but was not declared in scenario tag: bar -''' +""" distributed_mock.assert_not_called() local_mock.assert_not_called() @@ -666,12 +669,12 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', - '--dump', f'{execution_context}/output.feature' + f'{execution_context.as_posix()}/features/test.feature', + '--dump', f'{execution_context.as_posix()}/output.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 @@ -722,12 +725,12 @@ def test_run_dump(capsys: CaptureFixture, mocker: MockerFixture, tmp_path_factor arguments = parser.parse_args([ 'run', - '-e', f'{execution_context}/configuration.yaml', + '-e', f'{execution_context.as_posix()}/configuration.yaml', '--yes', - f'{execution_context}/features/test.feature', - '--dump', f'{execution_context}/output.feature' + f'{execution_context.as_posix()}/features/test.feature', + '--dump', f'{execution_context.as_posix()}/output.feature', ]) - setattr(arguments, 'file', ' '.join(arguments.file)) + arguments.file = ' '.join(arguments.file) assert run(arguments, local_mock) == 0 diff --git a/tests/unit/utils/test___init__.py b/tests/unit/utils/test___init__.py index 8c21fd5..a66392d 100644 --- a/tests/unit/utils/test___init__.py +++ b/tests/unit/utils/test___init__.py @@ -1,37 +1,39 @@ -from typing import Any, Dict, List, Tuple, Union -from os import chdir, getcwd -from textwrap import dedent -from importlib import reload +from __future__ import annotations + from argparse import Namespace -from tempfile import gettempdir from contextlib import ExitStack +from importlib import reload +from json.decoder import JSONDecodeError +from pathlib import Path +from tempfile import gettempdir +from textwrap import dedent +from typing import TYPE_CHECKING, Any, Union +from unittest.mock import mock_open +from unittest.mock import patch as unittest_patch import pytest -from _pytest.tmpdir import TempPathFactory -from _pytest.capture import CaptureFixture -from pytest_mock import MockerFixture -from unittest.mock import mock_open, patch as unittest_patch -from requests_mock import Mocker as RequestsMocker - from grizzly_cli.utils import ( - parse_feature_file, - list_images, + ask_yes_no, + distribution_of_users_per_scenario, + find_metadata_notices, + find_variable_names_in_questions, get_default_mtu, + get_dependency_versions, + get_distributed_system, + list_images, + parse_feature_file, requirements, run_command, - get_distributed_system, - find_variable_names_in_questions, - distribution_of_users_per_scenario, - ask_yes_no, - get_dependency_versions, - find_metadata_notices, setup_logging, ) +from tests.helpers import create_scenario, cwd, rm_rf -from tests.helpers import create_scenario, rm_rf - -CWD = getcwd() +if TYPE_CHECKING: # pragma: no cover + from _pytest.capture import CaptureFixture + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture + from requests_mock import Mocker as RequestsMocker def test_parse_feature_file(tmp_path_factory: TempPathFactory) -> None: @@ -39,7 +41,7 @@ def test_parse_feature_file(tmp_path_factory: TempPathFactory) -> None: test_context_root = str(test_context) feature_file = test_context / 'test.feature' feature_file.touch() - feature_file.write_text(dedent(''' + feature_file.write_text(dedent(""" Feature: test feature Background: Given a common test step @@ -51,50 +53,47 @@ def test_parse_feature_file(tmp_path_factory: TempPathFactory) -> None: Given a second test step Then execute it When done, just stop - ''')) - - chdir(test_context_root) + """)) try: - import grizzly_cli - reload(grizzly_cli) - reload(grizzly_cli.utils) + with cwd(test_context): + import grizzly_cli # noqa: PLC0415 + reload(grizzly_cli) + reload(grizzly_cli.utils) - assert len(grizzly_cli.SCENARIOS) == 0 + assert len(grizzly_cli.SCENARIOS) == 0 - parse_feature_file('test.feature') + parse_feature_file('test.feature') - import grizzly_cli + import grizzly_cli # noqa: PLC0415 - cached_scenarios = grizzly_cli.SCENARIOS.copy() - assert len(grizzly_cli.SCENARIOS) == 2 - assert list(grizzly_cli.SCENARIOS)[0].name == 'scenario-1' - assert len(list(grizzly_cli.SCENARIOS)[0].steps) == 2 - assert len(list(grizzly_cli.SCENARIOS)[0].background_steps) == 2 - assert list(grizzly_cli.SCENARIOS)[1].name == 'scenario-2' - assert len(list(grizzly_cli.SCENARIOS)[1].steps) == 3 - assert len(list(grizzly_cli.SCENARIOS)[1].background_steps) == 2 + cached_scenarios = grizzly_cli.SCENARIOS.copy() + assert len(grizzly_cli.SCENARIOS) == 2 + assert next(iter(grizzly_cli.SCENARIOS)).name == 'scenario-1' + assert len(next(iter(grizzly_cli.SCENARIOS)).steps) == 2 + assert len(next(iter(grizzly_cli.SCENARIOS)).background_steps) == 2 + assert list(grizzly_cli.SCENARIOS)[1].name == 'scenario-2' + assert len(list(grizzly_cli.SCENARIOS)[1].steps) == 3 + assert len(list(grizzly_cli.SCENARIOS)[1].background_steps) == 2 - parse_feature_file('test.feature') + parse_feature_file('test.feature') - import grizzly_cli - - assert grizzly_cli.SCENARIOS == cached_scenarios + import grizzly_cli # noqa: PLC0415 + assert cached_scenarios == grizzly_cli.SCENARIOS finally: - chdir(CWD) rm_rf(test_context_root) def test_list_images(mocker: MockerFixture) -> None: check_output = mocker.patch('grizzly_cli.utils.subprocess.check_output', side_effect=[( - '{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.10", "size": "1.16GB", "created": "2021-12-02 23:46:55 +0100 CET", "id": "a05f8cc8454b"}\n' - '{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.10-bullseye", "size": "1.16GB", "created": "2021-12-02 23:46:55 +0100 CET", "id": "a05f8cc8454b"}\n' - '{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.9", "size": "1.23GB", "created": "2021-12-02 23:27:50 +0100 CET", "id": "bfbce224d490"}\n' - '{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.8", "size": "1.23GB", "created": "2021-12-02 23:10:12 +0100 CET", "id": "8a04d9e5df14"}\n' - '{"name": "mcr.microsoft.com/vscode/devcontainers/base", "tag": "0-focal", "size": "343MB", "created": "2021-12-02 22:44:23 +0100 CET", "id": "0cc1cbb6d08d"}\n' - '{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.6", "size": "1.22GB", "created": "2021-12-02 22:17:47 +0100 CET", "id": "cc5abbf52b04"}\n' - ).encode()]) + b'{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.10", "size": "1.16GB", "created": "2021-12-02 23:46:55 +0100 CET", "id": "a05f8cc8454b"}\n' + b'{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.10-bullseye", "size": "1.16GB", "created": "2021-12-02 23:46:55 +0100 CET", "id": "a05f8cc8454b"}\n' + b'{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.9", "size": "1.23GB", "created": "2021-12-02 23:27:50 +0100 CET", "id": "bfbce224d490"}\n' + b'{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.8", "size": "1.23GB", "created": "2021-12-02 23:10:12 +0100 CET", "id": "8a04d9e5df14"}\n' + b'{"name": "mcr.microsoft.com/vscode/devcontainers/base", "tag": "0-focal", "size": "343MB", "created": "2021-12-02 22:44:23 +0100 CET", "id": "0cc1cbb6d08d"}\n' + b'{"name": "mcr.microsoft.com/vscode/devcontainers/python", "tag": "0-3.6", "size": "1.22GB", "created": "2021-12-02 22:17:47 +0100 CET", "id": "cc5abbf52b04"}\n' + )]) arguments = Namespace(container_system='capsulegirl') @@ -111,32 +110,31 @@ def test_list_images(mocker: MockerFixture) -> None: ] assert len(images.keys()) == 2 - assert sorted(list(images.get('mcr.microsoft.com/vscode/devcontainers/python', {}).keys())) == sorted([ + assert sorted(images.get('mcr.microsoft.com/vscode/devcontainers/python', {}).keys()) == sorted([ '0-3.10', '0-3.10-bullseye', '0-3.9', '0-3.8', '0-3.6', ]) - assert sorted(list(images.get('mcr.microsoft.com/vscode/devcontainers/base', {}).keys())) == sorted([ - '0-focal' + assert sorted(images.get('mcr.microsoft.com/vscode/devcontainers/base', {}).keys()) == sorted([ + '0-focal', ]) def test_get_default_mtu(mocker: MockerFixture) -> None: - from json.decoder import JSONDecodeError check_output = mocker.patch('grizzly_cli.utils.subprocess.check_output', side_effect=[ JSONDecodeError, ( - '{"com.docker.network.bridge.default_bridge":"true","com.docker.network.bridge.enable_icc":"true",' - '"com.docker.network.bridge.enable_ip_masquerade":"true","com.docker.network.bridge.host_binding_ipv4":"0.0.0.0",' - '"com.docker.network.bridge.name":"docker0","com.docker.network.driver.mtu":"1500"}\n' - ).encode(), + b'{"com.docker.network.bridge.default_bridge":"true","com.docker.network.bridge.enable_icc":"true",' + b'"com.docker.network.bridge.enable_ip_masquerade":"true","com.docker.network.bridge.host_binding_ipv4":"0.0.0.0",' + b'"com.docker.network.bridge.name":"docker0","com.docker.network.driver.mtu":"1500"}\n' + ), ( - '{"com.docker.network.bridge.default_bridge":"true","com.docker.network.bridge.enable_icc":"true",' - '"com.docker.network.bridge.enable_ip_masquerade":"true","com.docker.network.bridge.host_binding_ipv4":"0.0.0.0",' - '"com.docker.network.bridge.name":"docker0","com.docker.network.driver.mtu":"1440"}\n' - ).encode(), + b'{"com.docker.network.bridge.default_bridge":"true","com.docker.network.bridge.enable_icc":"true",' + b'"com.docker.network.bridge.enable_ip_masquerade":"true","com.docker.network.bridge.host_binding_ipv4":"0.0.0.0",' + b'"com.docker.network.bridge.name":"docker0","com.docker.network.driver.mtu":"1440"}\n' + ), ]) arguments = Namespace(container_system='capsulegirl') @@ -166,9 +164,9 @@ def test_run_command(capsys: CaptureFixture, mocker: MockerFixture) -> None: terminate = mocker.patch('grizzly_cli.utils.subprocess.Popen.terminate', autospec=True) wait = mocker.patch('grizzly_cli.utils.subprocess.Popen.wait', autospec=True) - def popen___init___no_stdout(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> None: - setattr(args[0], 'returncode', 133) - setattr(args[0], 'stdout', None) + def popen___init___no_stdout(*args: Any, **_kwargs: Any) -> None: + args[0].returncode = 133 + args[0].stdout = None mocker.patch('grizzly_cli.utils.subprocess.Popen.__init__', popen___init___no_stdout) poll_mock = mocker.patch('grizzly_cli.utils.subprocess.Popen.poll', side_effect=[None]) @@ -185,17 +183,17 @@ def popen___init___no_stdout(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) - assert poll_mock.call_count == 1 assert kill_mock.call_count == 1 - def mock_command_output(output: List[str], returncode: int = 0) -> None: - output_buffer: List[Union[bytes, int]] = [f'{line}\n'.encode('utf-8') for line in output] + [0] + def mock_command_output(output: list[str], returncode: int = 0) -> None: + output_buffer: list[Union[bytes, int]] = [f'{line}\n'.encode() for line in output] + [0] - def popen___init__(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> None: - setattr(args[0], 'returncode', returncode) + def popen___init__(*args: Any, **_kwargs: Any) -> None: + args[0].returncode = returncode class Stdout: def readline(self) -> Union[bytes, int]: return output_buffer.pop(0) - setattr(args[0], 'stdout', Stdout()) + args[0].stdout = Stdout() mocker.patch('grizzly_cli.utils.subprocess.Popen.terminate', side_effect=[KeyboardInterrupt]) mocker.patch('grizzly_cli.utils.subprocess.Popen.__init__', popen___init__) @@ -256,7 +254,7 @@ def test_get_distributed_system(capsys: CaptureFixture, mocker: MockerFixture) - # test 1 which.side_effect = [None, None] - getstatusoutput.return_value = (1, 'foobar',) + getstatusoutput.return_value = (1, 'foobar') assert get_distributed_system() is None # neither capture = capsys.readouterr() assert capture.out == 'neither "podman" nor "docker" found in PATH\n' @@ -280,7 +278,7 @@ def test_get_distributed_system(capsys: CaptureFixture, mocker: MockerFixture) - # test 3 which.side_effect = [None, 'podman'] - getstatusoutput.return_value = (0, 'foobar',) + getstatusoutput.return_value = (0, 'foobar') assert get_distributed_system() == 'podman' capture = capsys.readouterr() assert which.call_count == 2 @@ -293,7 +291,7 @@ def test_get_distributed_system(capsys: CaptureFixture, mocker: MockerFixture) - # test 4 which.side_effect = ['docker'] - getstatusoutput.return_value = (1, 'foobar',) + getstatusoutput.return_value = (1, 'foobar') assert get_distributed_system() is None capture = capsys.readouterr() assert which.call_count == 1 @@ -306,7 +304,7 @@ def test_get_distributed_system(capsys: CaptureFixture, mocker: MockerFixture) - # test 5 which.side_effect = ['docker'] - getstatusoutput.return_value = (0, 'foobar',) + getstatusoutput.return_value = (0, 'foobar') assert get_distributed_system() == 'docker' capture = capsys.readouterr() assert which.call_count == 1 @@ -328,13 +326,12 @@ def test_find_variable_names_in_questions(mocker: MockerFixture) -> None: [ 'Given a user of type "RestApi" load testing "https://localhost"', 'And ask for value of variable test_variable_1', - ] + ], ), ]) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='could not find variable name in "ask for value of variable test_variable_1'): find_variable_names_in_questions('test.feature') - assert 'could not find variable name in "ask for value of variable test_variable_1"' in str(ve) mocker.patch('grizzly_cli.SCENARIOS', [ create_scenario( @@ -344,7 +341,7 @@ def test_find_variable_names_in_questions(mocker: MockerFixture) -> None: 'Given a user of type "RestApi" load testing "https://localhost"', 'And ask for value of variable "test_variable_2"', 'And ask for value of variable "test_variable_1"', - ] + ], ), create_scenario( 'scenario-2', @@ -354,48 +351,48 @@ def test_find_variable_names_in_questions(mocker: MockerFixture) -> None: [ 'Given a user of type "MessageQueueUser" load testing "mqs://localhost"', 'And ask for value of variable "foo"', - ] - ) + ], + ), ]) variables = find_variable_names_in_questions('test.feature') assert len(variables) == 4 assert variables == ['bar', 'foo', 'test_variable_1', 'test_variable_2'] -def test_find_metadata_notices(mocker: MockerFixture, tmp_path_factory: TempPathFactory) -> None: +def test_find_metadata_notices(tmp_path_factory: TempPathFactory) -> None: test_context = tmp_path_factory.mktemp('test_context') try: feature_file = test_context / 'test-1.feature' - feature_file.write_text('''Feature: test -1 + feature_file.write_text("""Feature: test -1 Scenario: hello world Given a feature file with a rich set of expressions -''') +""") assert find_metadata_notices(str(feature_file)) == [] - feature_file.write_text('''# grizzly-cli run --verbose + feature_file.write_text("""# grizzly-cli run --verbose # grizzly-cli:notice have you created testdata? Feature: test -1 Scenario: hello world Given a feature file with a rich set of expressions -''') +""") assert find_metadata_notices(str(feature_file)) == ['have you created testdata?'] - feature_file.write_text('''# grizzly-cli run --verbose + feature_file.write_text("""# grizzly-cli run --verbose # grizzly-cli:notice have you created testdata? Feature: test -1 Scenario: hello world # grizzly-cli:notice is the event log cleared? Given a feature file with a rich set of expressions -''') +""") assert find_metadata_notices(str(feature_file)) == ['have you created testdata?', 'is the event log cleared?'] finally: rm_rf(test_context) -def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: MockerFixture) -> None: +def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: MockerFixture) -> None: # noqa: PLR0915 setup_logging() arguments = Namespace(file='test.feature', yes=False) @@ -414,9 +411,8 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock ), ]) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='grizzly needs at least 1 users to run this feature'): distribution_of_users_per_scenario(arguments, {}) - assert str(ve.value) == 'grizzly needs at least 1 users to run this feature' mocker.patch('grizzly_cli.SCENARIOS', [ create_scenario( @@ -441,12 +437,11 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "1" iteration', 'And ask for value of variable "foo"', ], - ) + ), ]) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='scenario-1 will have 5 users to run 1 iterations, increase iterations or lower user count'): distribution_of_users_per_scenario(arguments, {}) - assert str(ve.value) == 'scenario-1 will have 5 users to run 1 iterations, increase iterations or lower user count' capsys.readouterr() @@ -473,7 +468,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "1" iteration', 'And ask for value of variable "foo"', ], - ) + ), ]) distribution_of_users_per_scenario(arguments, {}) @@ -491,7 +486,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock '001 1 1 1 scenario-1 \n', '002 1 1 1 scenario-2 \n', '------|-------|------|------|-------------|\n', - '\n' + '\n', ] assert capture.err == ''.join(expected_lines) capsys.readouterr() @@ -507,9 +502,8 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock ), ]) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='scenario "scenario-1" does not have any steps'): distribution_of_users_per_scenario(arguments, {}) - assert 'scenario "scenario-1" does not have any steps' in str(ve) mocker.patch('grizzly_cli.SCENARIOS', [ create_scenario( @@ -519,9 +513,8 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock ), ]) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError, match='scenario-1 does not have a user type'): distribution_of_users_per_scenario(arguments, {}) - assert 'scenario-1 does not have a user type' in str(ve) mocker.patch('grizzly_cli.SCENARIOS', [ create_scenario( @@ -534,7 +527,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "{{ integer * 0.10 }}" iterations' 'And ask for value of variable "test_variable_2"', 'And ask for value of variable "test_variable_1"', - ] + ], ), create_scenario( 'scenario-2', @@ -546,12 +539,12 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "{{ integer * 0.01 }}" iterations', 'And ask for value of variable "foo"', ], - ) + ), ]) - import grizzly_cli.utils + import grizzly_cli.utils # noqa: PLC0415 - render = mocker.spy(grizzly_cli.utils.Template, 'render') # type: ignore + render = mocker.spy(grizzly_cli.utils.Template, 'render') # type: ignore[attr-defined] distribution_of_users_per_scenario(arguments, { 'TESTDATA_VARIABLE_users': '40', @@ -566,19 +559,18 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file test.feature will execute in total 55 iterations divided on 2 scenarios\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n', - '001 500 50 39 scenario-1 \n', - '002 1 5 1 scenario-2 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]) + assert capture.err == """ +feature file test.feature will execute in total 55 iterations divided on 2 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 500 50 39 scenario-1 +002 1 5 1 scenario-2 +------|-------|------|------|-------------| + +""" capsys.readouterr() assert ask_yes_no.call_count == 2 args, _ = ask_yes_no.call_args_list[-1] @@ -605,7 +597,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "500" iterations' 'And ask for value of variable "test_variable_2"', 'And ask for value of variable "test_variable_1"', - ] + ], ), create_scenario( 'scenario-2 testing a lot more of many different things that scenario-1 does not test', @@ -624,8 +616,8 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock [ 'Given a user of type "RestApi" with weight "1" load testing "https://127.0.0.2"', 'And repeat for "10" iterations', - ] - ) + ], + ), ]) arguments = Namespace(file='integration.feature', yes=True) @@ -634,20 +626,19 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file integration.feature will execute in total 1260 iterations divided on 3 scenarios\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description \n', - '------|-------|------|------|--------------------------------------------------------------------------------------|\n' - '001 100 500 46 scenario-1 testing a lot of stuff \n', - '002 50 750 23 scenario-2 testing a lot more of many different things that scenario-1 does not test\n', - '003 1 10 1 scenario-3 \n', - '------|-------|------|------|--------------------------------------------------------------------------------------|\n', - '\n', - ]) + assert capture.err == """ +feature file integration.feature will execute in total 1260 iterations divided on 3 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|--------------------------------------------------------------------------------------| +001 100 500 46 scenario-1 testing a lot of stuff +002 50 750 23 scenario-2 testing a lot more of many different things that scenario-1 does not test +003 1 10 1 scenario-3 +------|-------|------|------|--------------------------------------------------------------------------------------| + +""" capsys.readouterr() assert ask_yes_no.call_count == 2 @@ -660,7 +651,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock [ 'Given a user of type "RestApi" with weight "25" load testing "https://localhost"', 'And repeat for "1" iterations', - ] + ], ), ]) @@ -668,18 +659,17 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file integration.feature will execute in total 1 iterations divided on 1 scenarios\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n' - '001 25 1 1 scenario-1 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]) + assert capture.err == """ +feature file integration.feature will execute in total 1 iterations divided on 1 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 25 1 1 scenario-1 +------|-------|------|------|-------------| + +""" capsys.readouterr() arguments = Namespace(file='integration.feature', yes=True, environment_file='environments/local.yaml') @@ -687,18 +677,17 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file integration.feature will execute in total 1 iterations divided on 1 scenarios with environment file environments/local.lock.yaml\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n' - '001 25 1 1 scenario-1 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]) + assert capture.err == """ +feature file integration.feature will execute in total 1 iterations divided on 1 scenarios with environment file environments/local.lock.yaml + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 25 1 1 scenario-1 +------|-------|------|------|-------------| + +""" capsys.readouterr() mocker.patch('grizzly_cli.SCENARIOS', [ @@ -712,7 +701,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock 'And repeat for "10" iterations' 'And ask for value of variable "test_variable_2"', 'And ask for value of variable "test_variable_1"', - ] + ], ), create_scenario( 'scenario-2 testing a lot more of many different things that scenario-1 does not test', @@ -729,7 +718,7 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock [ 'Given a user of type "RestApi" with weight "0" load testing "https://127.0.0.2"', 'And repeat for "10" iterations', - ] + ], ), create_scenario( 'scenario-4', @@ -737,163 +726,157 @@ def test_distribution_of_users_per_scenario(capsys: CaptureFixture, mocker: Mock [ 'Given a user of type "RestApi" with weight "0" load testing "https://127.0.0.2"', 'And repeat for "0" iterations', - ] - ) + ], + ), ]) arguments = Namespace(file='integration.feature', yes=True) - with pytest.raises(ValueError) as ve: + with pytest.raises(ValueError) as ve: # noqa: PT011 distribution_of_users_per_scenario(arguments, {}) capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file integration.feature will execute in total 20 iterations divided on 4 scenarios\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description errors\n', - '------|-------|------|------|--------------------------------------------------------------------------------------|----------------------------------|\n' - '001 1 10 1 scenario-1 testing a lot of stuff \n', - '002 50 0 3 scenario-2 testing a lot more of many different things that scenario-1 does not test no iterations\n', - '003 0 10 0 scenario-3 no users assigned\n', - '004 0 0 0 scenario-4 no users assigned, no iterations\n', - '------|-------|------|------|--------------------------------------------------------------------------------------|----------------------------------|\n', - ]) + assert capture.err == """ +feature file integration.feature will execute in total 20 iterations divided on 4 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description errors +------|-------|------|------|--------------------------------------------------------------------------------------|----------------------------------| +001 1 10 1 scenario-1 testing a lot of stuff +002 50 0 3 scenario-2 testing a lot more of many different things that scenario-1 does not test no iterations +003 0 10 0 scenario-3 no users assigned +004 0 0 0 scenario-4 no users assigned, no iterations +------|-------|------|------|--------------------------------------------------------------------------------------|----------------------------------| +""" assert str(ve.value) == """ ^ +-------------------------------------------------------------------------------------------------------------------+ | +- there were errors when calculating user distribution and iterations per scenario, adjust user "weight", number of users or iterations per scenario\n""" -@pytest.mark.parametrize('users,iterations,output', [ +@pytest.mark.parametrize( + ('users', 'iterations', 'output'), [ ( 6, 13, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 28 iterations divided on 7 scenarios\n' - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n' - '001 50 15 5 scenario-0 \n', - '002 12 3 1 scenario-1 \n', - '003 21 5 2 scenario-2 \n', - '004 4 1 1 scenario-3 \n', - '005 6 2 1 scenario-4 \n', - '006 3 1 1 scenario-5 \n', - '007 3 1 1 scenario-6 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]), + """ +feature file integration.feature will execute in total 28 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 50 15 5 scenario-0 +002 12 3 1 scenario-1 +003 21 5 2 scenario-2 +004 4 1 1 scenario-3 +005 6 2 1 scenario-4 +006 3 1 1 scenario-5 +007 3 1 1 scenario-6 +------|-------|------|------|-------------| + +""", ), ( 12, 20, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 43 iterations divided on 7 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n' - '001 33 23 6 scenario-0 \n', - '002 16 5 2 scenario-1 \n', - '003 28 8 5 scenario-2 \n', - '004 5 2 1 scenario-3 \n', - '005 8 3 2 scenario-4 \n', - '006 4 1 1 scenario-5 \n', - '007 4 1 1 scenario-6 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]), + """ +feature file integration.feature will execute in total 43 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 33 23 6 scenario-0 +002 16 5 2 scenario-1 +003 28 8 5 scenario-2 +004 5 2 1 scenario-3 +005 8 3 2 scenario-4 +006 4 1 1 scenario-5 +007 4 1 1 scenario-6 +------|-------|------|------|-------------| + +""", ), ( 18, 31, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 66 iterations divided on 7 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n', - '001 25 35 6 scenario-0 \n', - '002 18 8 4 scenario-1 \n', - '003 31 13 7 scenario-2 \n', - '004 6 2 2 scenario-3 \n', - '005 9 4 3 scenario-4 \n', - '006 4 2 1 scenario-5 \n', - '007 4 2 1 scenario-6 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]), + """ +feature file integration.feature will execute in total 66 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 25 35 6 scenario-0 +002 18 8 4 scenario-1 +003 31 13 7 scenario-2 +004 6 2 2 scenario-3 +005 9 4 3 scenario-4 +006 4 2 1 scenario-5 +007 4 2 1 scenario-6 +------|-------|------|------|-------------| + +""", ), ( 24, 49, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 105 iterations divided on 7 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n', - '001 20 56 6 scenario-0 \n', - '002 20 12 6 scenario-1 \n', - '003 33 21 10 scenario-2 \n', - '004 6 4 1 scenario-3 \n', - '005 10 6 3 scenario-4 \n', - '006 4 3 2 scenario-5 \n', - '007 4 3 2 scenario-6 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]) + """ +feature file integration.feature will execute in total 105 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 20 56 6 scenario-0 +002 20 12 6 scenario-1 +003 33 21 10 scenario-2 +004 6 4 1 scenario-3 +005 10 6 3 scenario-4 +006 4 3 2 scenario-5 +007 4 3 2 scenario-6 +------|-------|------|------|-------------| + +""", ), ( 30, 58, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 124 iterations divided on 7 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n', - '001 16 66 6 scenario-0 \n', - '002 20 15 7 scenario-1 \n', - '003 35 24 12 scenario-2 \n', - '004 6 5 3 scenario-3 \n', - '005 10 8 4 scenario-4 \n', - '006 5 3 2 scenario-5 \n', - '007 5 3 2 scenario-6 \n', - '------|-------|------|------|-------------|\n' - '\n', - ]) + """ +feature file integration.feature will execute in total 124 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 16 66 6 scenario-0 +002 20 15 7 scenario-1 +003 35 24 12 scenario-2 +004 6 5 3 scenario-3 +005 10 8 4 scenario-4 +006 5 3 2 scenario-5 +007 5 3 2 scenario-6 +------|-------|------|------|-------------| + +""", ), ( 30, 21000, - ''.join([ - '\n', - 'feature file integration.feature will execute in total 44940 iterations divided on 7 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident weight #iter #user description\n', - '------|-------|------|------|-------------|\n', - '001 16 23940 6 scenario-0 \n', - '002 20 5250 7 scenario-1 \n', - '003 35 8820 12 scenario-2 \n', - '004 6 1680 3 scenario-3 \n', - '005 10 2730 4 scenario-4 \n', - '006 5 1260 2 scenario-5 \n', - '007 5 1260 2 scenario-6 \n', - '------|-------|------|------|-------------|\n', - '\n', - ]) + """ +feature file integration.feature will execute in total 44940 iterations divided on 7 scenarios + +each scenario will execute accordingly: + +ident weight #iter #user description +------|-------|------|------|-------------| +001 16 23940 6 scenario-0 +002 20 5250 7 scenario-1 +003 35 8820 12 scenario-2 +004 6 1680 3 scenario-3 +005 10 2730 4 scenario-4 +006 5 1260 2 scenario-5 +007 5 1260 2 scenario-6 +------|-------|------|------|-------------| + +""", ), ]) def test_distribution_of_users_per_scenario_advanced(capsys: CaptureFixture, mocker: MockerFixture, users: int, iterations: int, output: str) -> None: @@ -903,7 +886,7 @@ def test_distribution_of_users_per_scenario_advanced(capsys: CaptureFixture, moc # grizzly will later make sure that they are only run once background_steps = [ 'Given "{{ (users | int) + 6 }}" users', - 'And spawn rate is "{{ rate }}" users per second' + 'And spawn rate is "{{ rate }}" users per second', ] mocker.patch('grizzly_cli.SCENARIOS', [ @@ -921,7 +904,7 @@ def test_distribution_of_users_per_scenario_advanced(capsys: CaptureFixture, moc [ 'Given a user of type "RestApi" with weight "{{ (100 - ((6 / ((users | int) + 6) + 0.5 | int) * 100)) * 0.25 }}" load testing "https://localhost"', 'And repeat for "{{ ((leveranser * 0.25) + 0.5) | int }}" iterations', - ] + ], ), create_scenario( 'scenario-2', @@ -985,7 +968,7 @@ def test_distribution_of_users_per_scenario_no_weights(capsys: CaptureFixture, m # all scenarios in a feature file will, at this point, have all the background steps # grizzly will later make sure that they are only run once background_steps = [ - 'Given spawn rate is "{{ rate }}" users per second' + 'Given spawn rate is "{{ rate }}" users per second', ] mocker.patch('grizzly_cli.SCENARIOS', [ @@ -1003,7 +986,7 @@ def test_distribution_of_users_per_scenario_no_weights(capsys: CaptureFixture, m [ 'Given "{{ ((((max_users_undefined * 0.3) - 0.5) | int) or 1) if max_users_undefined is defined else 1 }}" user of type "RestApi" load testing "https://localhost"', 'And repeat for "{{ ((leveranser * 0.3) + 0.5) | int }}" iterations', - ] + ], ), ]) @@ -1017,19 +1000,18 @@ def test_distribution_of_users_per_scenario_no_weights(capsys: CaptureFixture, m capture = capsys.readouterr() assert capture.out == '' - assert capture.err == ''.join([ - '\n', - 'feature file integration.feature will execute in total 23 iterations divided on 2 scenarios\n', - '\n', - 'each scenario will execute accordingly:\n', - '\n', - 'ident #iter #user description\n', - '------|------|------|-------------|\n', - '001 20 6 scenario-0 \n', - '002 3 1 scenario-1 \n', - '------|------|------|-------------|\n', - '\n', - ]) + assert capture.err == """ +feature file integration.feature will execute in total 23 iterations divided on 2 scenarios + +each scenario will execute accordingly: + +ident #iter #user description +------|------|------|-------------| +001 20 6 scenario-0 +002 3 1 scenario-1 +------|------|------|-------------| + +""" capsys.readouterr() @@ -1058,28 +1040,28 @@ def test_ask_yes_no(capsys: CaptureFixture, mocker: MockerFixture) -> None: assert args[0] == 'are you sure you know what you are doing? [y/n]: ' -def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: TempPathFactory, capsys: CaptureFixture) -> None: +def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: TempPathFactory, capsys: CaptureFixture) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') requirements_file = test_context / 'requirements.txt' mocker.patch('grizzly_cli.EXECUTION_CONTEXT', str(test_context)) try: - grizzly_versions, locust_version = get_dependency_versions(False) + grizzly_versions, locust_version = get_dependency_versions(local_install=False) - assert grizzly_versions == (None, None,) + assert grizzly_versions == (None, None) assert locust_version is None requirements_file.touch() - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == f'!! unable to find grizzly dependency in {requirements_file.absolute()}\n' assert capture.out == '' requirements_file.write_text('git+https://github.com/Biometria-se/grizzly.git@v1.5.3#egg=grizzly-loadtester') - import subprocess + import subprocess # noqa: PLC0415 with ExitStack() as stack: stack.enter_context( mocker.patch.context_manager(subprocess, 'check_call', side_effect=[1, 0, 0, 1, 0]), @@ -1093,74 +1075,78 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te ]), ) - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to clone git repo https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' - assert subprocess.check_call.call_count == 1 # type: ignore # pylint: disable=no-member - assert subprocess.check_output.call_count == 0 # type: ignore # pylint: disable=no-member + assert subprocess.check_call.call_count == 1 # type: ignore[attr-defined] + assert subprocess.check_output.call_count == 0 # type: ignore[attr-defined] # git clone... - args, kwargs = subprocess.check_call.call_args_list[0] # type: ignore # pylint: disable=no-member + args, kwargs = subprocess.check_call.call_args_list[0] # type: ignore[attr-defined] assert len(args) == 1 args = args[0] assert args[:-1] == ['git', 'clone', '--filter=blob:none', '-q', 'https://github.com/Biometria-se/grizzly.git'] - assert args[-1].startswith(gettempdir()) - assert args[-1].endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') + assert isinstance(args[-1], Path) + assert args[-1].as_posix().startswith(Path(gettempdir()).as_posix()) + assert args[-1].as_posix().endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') assert not kwargs.get('shell', True) assert kwargs.get('stdout', None) == subprocess.DEVNULL assert kwargs.get('stderr', None) == subprocess.DEVNULL - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to check branch name of HEAD in git repo https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' - assert subprocess.check_call.call_count == 2 # type: ignore # pylint: disable=no-member - assert subprocess.check_output.call_count == 1 # type: ignore # pylint: disable=no-member + assert subprocess.check_call.call_count == 2 # type: ignore[attr-defined] + assert subprocess.check_output.call_count == 1 # type: ignore[attr-defined] # git rev-parse... - args, kwargs = subprocess.check_output.call_args_list[0] # type: ignore # pylint: disable=no-member + args, kwargs = subprocess.check_output.call_args_list[0] # type: ignore[attr-defined] assert len(args) == 1 args = args[0] assert args == ['git', 'rev-parse', '--abbrev-ref', 'HEAD'] assert not kwargs.get('shell', True) - assert kwargs.get('cwd', '').startswith(gettempdir()) - assert kwargs.get('cwd', '').endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') + kwarg_cwd = kwargs.get('cwd', None) + assert isinstance(kwarg_cwd, Path) + assert kwarg_cwd.as_posix().startswith(Path(gettempdir()).as_posix()) + assert kwarg_cwd.as_posix().endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') assert kwargs.get('universal_newlines', False) - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(True) + assert get_dependency_versions(local_install=True) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to checkout branch v1.5.3 from git repo https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' - assert subprocess.check_call.call_count == 4 # type: ignore # pylint: disable=no-member - assert subprocess.check_output.call_count == 3 # type: ignore # pylint: disable=no-member + assert subprocess.check_call.call_count == 4 # type: ignore[attr-defined] + assert subprocess.check_output.call_count == 3 # type: ignore[attr-defined] # git checkout... - args, kwargs = subprocess.check_call.call_args_list[-1] # type: ignore # pylint: disable=no-member + args, kwargs = subprocess.check_call.call_args_list[-1] # type: ignore[attr-defined] assert len(args) == 1 args = args[0] assert args == ['git', 'checkout', '-b', 'v1.5.3', '--track', 'origin/v1.5.3'] - assert kwargs.get('cwd', '').startswith(gettempdir()) - assert kwargs.get('cwd', '').endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') + kwarg_cwd = kwargs.get('cwd', None) + assert kwarg_cwd.as_posix().startswith(Path(gettempdir()).as_posix()) + assert kwarg_cwd.as_posix().endswith('grizzly-loadtester_3f210f1809f6ca85ef414b2b4d450bf54353b5e0') assert not kwargs.get('shell', True) assert kwargs.get('stdout', None) == subprocess.DEVNULL assert kwargs.get('stderr', None) == subprocess.DEVNULL with pytest.raises(FileNotFoundError): - get_dependency_versions(False) + get_dependency_versions(local_install=False) capture = capsys.readouterr() assert capture.err == '' assert capture.out == '' - assert subprocess.check_call.call_count == 5 # type: ignore # pylint: disable=no-member - assert subprocess.check_output.call_count == 4 # type: ignore # pylint: disable=no-member + assert subprocess.check_call.call_count == 5 # type: ignore[attr-defined] + assert subprocess.check_output.call_count == 4 # type: ignore[attr-defined] with ExitStack() as stack: stack.enter_context(mocker.patch.context_manager(subprocess, 'check_call', return_value=0)) @@ -1169,27 +1155,27 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te ) with pytest.raises(FileNotFoundError) as fne: - get_dependency_versions(False) + get_dependency_versions(local_install=False) assert fne.value.errno == 2 assert fne.value.strerror == 'No such file or directory' - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@v1.5.3#egg=grizzly-loadtester\n').return_value, mock_open(read_data='').return_value, ]) as open_mock: - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find "__version__" declaration in grizzly/__init__.py from https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' assert open_mock.call_count == 2 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@v1.5.3#egg=grizzly-loadtester\n').return_value, mock_open(read_data="__version__ = '0.0.0'").return_value, mock_open(read_data='').return_value, ]) as open_mock: - assert (('(development)', [], ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(development)', []), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find "locust" dependency in requirements.txt from https://github.com/Biometria-se/grizzly.git\n' @@ -1197,12 +1183,12 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te assert open_mock.call_count == 3 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@v1.5.3#egg=grizzly-loadtester[dev,mq]\n').return_value, mock_open(read_data="__version__ = '1.5.3'").return_value, mock_open(read_data='locust').return_value, ]) as open_mock: - assert (('1.5.3', ['dev', 'mq'], ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.5.3', ['dev', 'mq']), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find locust version in "locust" specified in requirements.txt from https://github.com/Biometria-se/grizzly.git\n' @@ -1210,12 +1196,12 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te assert open_mock.call_count == 3 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@v1.5.3#egg=grizzly-loadtester\n').return_value, mock_open(read_data="__version__ = '1.5.3'").return_value, mock_open(read_data='locust==2.2.1 \\ ').return_value, ]) as open_mock: - assert (('1.5.3', [], ), '2.2.1',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.5.3', []), '2.2.1') capture = capsys.readouterr() assert capture.err == '' @@ -1223,63 +1209,63 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te assert open_mock.call_count == 3 - mocker.patch('grizzly_cli.utils.path.exists', return_value=True) + mocker.patch('grizzly_cli.utils.Path.exists', return_value=True) with pytest.raises(FileNotFoundError) as fne: - get_dependency_versions(False) + get_dependency_versions(local_install=False) assert fne.value.errno == 2 assert fne.value.strerror == 'No such file or directory' - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@main#egg=grizzly-loadtester\n').return_value, mock_open(read_data='').return_value, ]) as open_mock: - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find "version" declaration in setup.cfg from https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' assert open_mock.call_count == 2 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='git+https://github.com/Biometria-se/grizzly.git@main#egg=grizzly-loadtester[mq]\n').return_value, mock_open(read_data='name = grizzly-loadtester\nversion = 2.0.0').return_value, mock_open(read_data='locust==2.8.4 \\ ').return_value, ]) as open_mock: - assert (('2.0.0', ['mq'], ), '2.8.4',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('2.0.0', ['mq']), '2.8.4') capture = capsys.readouterr() assert capture.err == '' assert capture.out == '' assert open_mock.call_count == 3 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='grizzly-loadtester @ git+https://github.com/Biometria-se/grizzly.git@main\n').return_value, mock_open(read_data='').return_value, ]) as open_mock: - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find "version" declaration in setup.cfg from https://github.com/Biometria-se/grizzly.git\n' assert capture.out == '' assert open_mock.call_count == 2 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='grizzly-loadtester[mq] @ git+https://github.com/Biometria-se/grizzly.git@main\n').return_value, mock_open(read_data='name = grizzly-loadtester\nversion = 2.0.0').return_value, mock_open(read_data='locust==2.8.4 \\ ').return_value, ]) as open_mock: - assert (('2.0.0', ['mq'], ), '2.8.4',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('2.0.0', ['mq']), '2.8.4') capture = capsys.readouterr() assert capture.err == '' assert capture.out == '' assert open_mock.call_count == 3 - with unittest_patch('builtins.open', side_effect=[ + with unittest_patch('grizzly_cli.utils.Path.open', side_effect=[ mock_open(read_data='grizzly-loadtester[mq] % git+https://github.com/Biometria-se/grizzly.git@main\n').return_value, ]) as open_mock: - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == f'!! unable to find properly formatted grizzly dependency in {requirements_file}\n' @@ -1290,21 +1276,21 @@ def test_get_dependency_versions_git(mocker: MockerFixture, tmp_path_factory: Te @pytest.mark.filterwarnings('ignore:Creating a LegacyVersion has been deprecated') -def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: TempPathFactory, capsys: CaptureFixture, requests_mock: RequestsMocker) -> None: +def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: TempPathFactory, capsys: CaptureFixture, requests_mock: RequestsMocker) -> None: # noqa: PLR0915 test_context = tmp_path_factory.mktemp('test_context') requirements_file = test_context / 'requirements.txt' mocker.patch('grizzly_cli.EXECUTION_CONTEXT', str(test_context)) try: - grizzly_versions, locust_version = get_dependency_versions(False) + grizzly_versions, locust_version = get_dependency_versions(local_install=False) - assert grizzly_versions == (None, None,) + assert grizzly_versions == (None, None) assert locust_version is None requirements_file.touch() - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == f'!! unable to find grizzly dependency in {requirements_file.absolute()}\n' @@ -1314,7 +1300,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/json', status_code=404) - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to get grizzly package information from https://pypi.org/pypi/grizzly-loadtester/json (404)\n' @@ -1323,7 +1309,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/json', status_code=200, text='{"info": {"version": "1.1.1"}}') requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.1.1/json', status_code=400) - assert (('1.1.1', [], ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.1.1', []), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to get grizzly 1.1.1 package information from https://pypi.org/pypi/grizzly-loadtester/1.1.1/json (400)\n' @@ -1331,7 +1317,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.1.1/json', status_code=200, text='{"info": {"requires_dist": []}}') - assert (('1.1.1', [], ), '(unknown)',) == get_dependency_versions(True) + assert get_dependency_versions(local_install=True) == (('1.1.1', []), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! could not find "locust" in requires_dist information for grizzly-loadtester 1.1.1\n' @@ -1342,9 +1328,9 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requirements_file.unlink() requirements_file.write_text('grizzly-loadtester[dev,mq]') - actual_dependency_versions = get_dependency_versions(True) + actual_dependency_versions = get_dependency_versions(local_install=True) - assert (('1.1.1', ['dev', 'mq'], ), '(unknown)',) == actual_dependency_versions + assert actual_dependency_versions == (('1.1.1', ['dev', 'mq']), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! unable to find locust version in "locust" specified in pypi for grizzly-loadtester 1.1.1\n' @@ -1352,7 +1338,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.1.1/json', status_code=200, text='{"info": {"requires_dist": ["locust (==2.8.5)"]}}') - assert (('1.1.1', ['dev', 'mq'], ), '2.8.5',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.1.1', ['dev', 'mq']), '2.8.5') capture = capsys.readouterr() assert capture.err == '' @@ -1363,7 +1349,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/json', status_code=200, text='{"releases": {"1.3.0": [], "1.5.0": []}}') - assert (('(unknown)', None, ), '(unknown)',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('(unknown)', None), '(unknown)') capture = capsys.readouterr() assert capture.err == '!! could not resolve grizzly-loadtester[mq]==1.4.0 to one specific version available at pypi\n' @@ -1377,7 +1363,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T ) requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.4.0/json', status_code=200, text='{"info": {"requires_dist": ["locust (==1.0.0)"]}}') - assert (('1.4.0', ['mq'], ), '1.0.0',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.4.0', ['mq']), '1.0.0') capture = capsys.readouterr() assert capture.err == '' @@ -1391,7 +1377,7 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T ) requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.5.0/json', status_code=200, text='{"info": {"requires_dist": ["locust (==1.1.1)"]}}') - assert (('1.5.0', ['mq'], ), '1.1.1',) == get_dependency_versions(False) + assert get_dependency_versions(local_install=False) == (('1.5.0', ['mq']), '1.1.1') capture = capsys.readouterr() assert capture.err == '' @@ -1406,8 +1392,8 @@ def test_get_dependency_versions_pypi(mocker: MockerFixture, tmp_path_factory: T ) requests_mock.register_uri('GET', 'https://pypi.org/pypi/grizzly-loadtester/1.4.0/json', status_code=200, text='{"info": {"requires_dist": ["locust (==1.0.0)"]}}') - assert (('1.4.0', ['mq'], ), '1.0.0',) == get_dependency_versions('a/b/') - assert (('1.4.0', ['mq'], ), '1.0.0',) == get_dependency_versions('a/b/requirements.txt') + assert get_dependency_versions(local_install='a/b/') == (('1.4.0', ['mq']), '1.0.0') + assert get_dependency_versions(local_install='a/b/requirements.txt') == (('1.4.0', ['mq']), '1.0.0') finally: rm_rf(test_context) @@ -1416,15 +1402,15 @@ def test_requirements(capsys: CaptureFixture, tmp_path_factory: TempPathFactory) test_context = tmp_path_factory.mktemp('test_context') requirements_file = test_context / 'requirements.txt' - def wrapped_test(args: Namespace) -> int: + def wrapped_test(_args: Namespace) -> int: return 1337 try: assert not requirements_file.exists() - wrapped = requirements(str(test_context))(wrapped_test) + wrapped = requirements(test_context.as_posix())(wrapped_test) assert getattr(wrapped, '__wrapped__', None) is wrapped_test - assert getattr(getattr(wrapped, '__wrapped__'), '__value__') == str(test_context) + assert getattr(getattr(wrapped, '__wrapped__'), '__value__') == test_context.as_posix() # noqa: B009 assert wrapped(Namespace()) == 1337 diff --git a/tests/unit/utils/test_configuration.py b/tests/unit/utils/test_configuration.py index 38b3650..7545c8d 100644 --- a/tests/unit/utils/test_configuration.py +++ b/tests/unit/utils/test_configuration.py @@ -1,31 +1,33 @@ from __future__ import annotations -from pathlib import Path -from unittest.mock import MagicMock from base64 import b64encode +from pathlib import Path from platform import system +from typing import TYPE_CHECKING +from unittest.mock import MagicMock import pytest -from _pytest.tmpdir import TempPathFactory -from pytest_mock import MockerFixture from azure.core.exceptions import ClientAuthenticationError, ServiceRequestError from azure.identity import ChainedTokenCredential -from azure.keyvault.secrets import SecretClient, SecretProperties, KeyVaultSecret +from azure.keyvault.secrets import KeyVaultSecret, SecretClient, SecretProperties -from grizzly_cli.utils import setup_logging, chunker +from grizzly_cli.utils import chunker, setup_logging from grizzly_cli.utils.configuration import ( ScenarioTag, - load_configuration_file, - load_configuration, - load_configuration_keyvault, - get_keyvault_client, _get_metadata, - _write_file, _import_files, + _write_file, get_context_root, + get_keyvault_client, + load_configuration, + load_configuration_file, + load_configuration_keyvault, ) +from tests.helpers import ANY, cwd, rm_rf -from tests.helpers import rm_rf, cwd, ANY +if TYPE_CHECKING: + from _pytest.tmpdir import TempPathFactory + from pytest_mock import MockerFixture def create_secret_property(name: str, content_type: str | None = None) -> SecretProperties: @@ -186,10 +188,7 @@ def test__write_file(tmp_path_factory: TempPathFactory) -> None: assert _write_file(test_context, content_type, chunk) == 'files/foobar.txt' f = test_context / 'files' / 'foobar.txt' - if index < number_of_chunks - 1: - expected = ''.join(processed_chunks) - else: - expected = 'foobarfoobarfoobar' + expected = ''.join(processed_chunks) if index < number_of_chunks - 1 else 'foobarfoobarfoobar' assert f.read_text() == expected @@ -267,17 +266,15 @@ def test_load_configuration(mocker: MockerFixture, tmp_path_factory: TempPathFac try: env_file_local = test_context / 'local.yaml' - env_file_local.write_text('''configuration: + env_file_local.write_text("""configuration: authentication: admin: username: administrator password: hunter -''') - - env_file_lock_name = load_configuration(env_file_local.as_posix()) - assert env_file_lock_name == f'{test_context.as_posix()}/local.lock.yaml' +""") - env_file_lock = Path(env_file_lock_name) + env_file_lock = load_configuration(env_file_local) + assert env_file_lock.as_posix() == Path.joinpath(test_context, 'local.lock.yaml').as_posix() if system() != 'Windows': assert env_file_lock.stat().st_mode & 0x000FFF == 0o600 @@ -286,56 +283,51 @@ def test_load_configuration(mocker: MockerFixture, tmp_path_factory: TempPathFac load_configuration_keyvault_mock.assert_not_called() with cwd(test_context): - env_file_lock_name = load_configuration('local.yaml') - assert env_file_lock_name == 'local.lock.yaml' + env_file_lock = load_configuration(Path('local.yaml')) + assert env_file_lock.as_posix() == 'local.lock.yaml' - env_file_lock = Path(env_file_lock_name) assert env_file_lock.read_text() == env_file_local.read_text() load_configuration_keyvault_mock.assert_not_called() - env_file_local.write_text('''configuration: + env_file_local.write_text("""configuration: keyvault: https://grizzly.keyvault.azure.com authentication: admin: username: administrator password: hunter -''') - - env_file_lock_name = load_configuration(env_file_local.as_posix()) - assert env_file_lock_name == f'{test_context.as_posix()}/local.lock.yaml' +""") - env_file_lock = Path(env_file_lock_name) + env_file_lock = load_configuration(env_file_local) + assert env_file_lock.as_posix() == Path.joinpath(test_context, 'local.lock.yaml').as_posix() assert env_file_lock.read_text() == env_file_local.read_text() load_configuration_keyvault_mock.assert_called_once_with(ANY(SecretClient), 'local', context_root, filter_keys=None) load_configuration_keyvault_mock.reset_mock() - env_file_local.write_text('''configuration: + env_file_local.write_text("""configuration: env: test keyvault: https://grizzly.keyvault.azure.com authentication: admin: username: administrator password: hunter -''') - - env_file_lock_name = load_configuration(env_file_local.as_posix()) - assert env_file_lock_name == f'{test_context.as_posix()}/local.lock.yaml' +""") - env_file_lock = Path(env_file_lock_name) + env_file_lock = load_configuration(env_file_local) + assert env_file_lock.as_posix() == Path.joinpath(test_context, 'local.lock.yaml').as_posix() assert env_file_lock.read_text() == env_file_local.read_text() load_configuration_keyvault_mock.assert_called_once_with(ANY(SecretClient), 'test', context_root, filter_keys=None) load_configuration_keyvault_mock.reset_mock() + dummy_file = test_context / 'dummy.txt' with pytest.raises(ValueError, match='dummy.txt does not exist'): - load_configuration('dummy.txt') + load_configuration(dummy_file) - dummy_file = test_context / 'dummy.txt' dummy_file.touch() with pytest.raises(ValueError, match='configuration file must have file extension yml or yaml'): - load_configuration(dummy_file.as_posix()) + load_configuration(dummy_file) finally: rm_rf(test_context) @@ -346,12 +338,12 @@ def test_load_configuration_file(tmp_path_factory: TempPathFactory) -> None: try: env_file_base = test_context / 'base.yaml' - env_file_base.write_text('''configuration: + env_file_base.write_text("""configuration: authentication: admin: username: administrator password: hunter -''') +""") assert load_configuration_file(env_file_base) == { 'configuration': { @@ -365,7 +357,7 @@ def test_load_configuration_file(tmp_path_factory: TempPathFactory) -> None: } env_file_local = test_context / 'local.yaml' - env_file_local.write_text('''{% merge "./base.yaml" %} + env_file_local.write_text("""{% merge "./base.yaml" %} configuration: authentication: admin: @@ -374,7 +366,7 @@ def test_load_configuration_file(tmp_path_factory: TempPathFactory) -> None: logging: level: DEBUG max_size: 10000 -''') +""") assert load_configuration_file(env_file_local) == { 'configuration': { 'authentication': { diff --git a/tests/webserver.py b/tests/webserver.py index c0beb55..75916fa 100644 --- a/tests/webserver.py +++ b/tests/webserver.py @@ -1,16 +1,18 @@ +from __future__ import annotations + import csv import logging - -from typing import Any, Optional, Type, cast -from typing_extensions import Literal -from types import TracebackType from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional import gevent - +from flask import Flask, jsonify, request +from flask import Response as FlaskResponse from gevent.pywsgi import WSGIServer +from typing_extensions import Literal, Self -from flask import Flask, request, jsonify, Response as FlaskResponse +if TYPE_CHECKING: + from types import TracebackType logger = logging.getLogger('webserver') @@ -34,7 +36,8 @@ def app_get_cat_fact() -> FlaskResponse: @app.route('/books/.json') def app_get_book(book: str) -> FlaskResponse: - with open(f'{Path.cwd()}/features/requests/books/books.csv', 'r') as fd: + books = Path.joinpath(Path.cwd(), 'features', 'requests', 'books', 'books.csv') + with books.open('r') as fd: reader = csv.DictReader(fd) for row in reader: if row['book'] == book: @@ -43,7 +46,7 @@ def app_get_book(book: str) -> FlaskResponse: 'isbn_10': [row['isbn_10']] * 2, 'authors': [ {'key': '/author/' + row['author'].replace(' ', '_').strip() + '|' + row['isbn_10'].strip()}, - ] + ], }) response = jsonify({'success': False}) @@ -57,7 +60,7 @@ def app_get_author(author_key: str) -> FlaskResponse: name, _ = author_key.rsplit('|', 1) return jsonify({ - 'name': name.replace('_', ' ') + 'name': name.replace('_', ' '), }) @@ -75,31 +78,34 @@ def __init__(self, port: int = 0) -> None: app, log=None, ) - logger.debug(f'created webserver on port {port}') + logger.debug('created webserver on port %d', port) @property def port(self) -> int: - return cast(int, self._web_server.server_port) + port = self._web_server.server_port + assert port is not None + + return port # type: ignore[no-any-return] def start(self) -> None: gevent.spawn(lambda: self._web_server.serve_forever()) gevent.sleep(0.01) - logger.debug(f'started webserver on port {self.port}') + logger.debug('started webserver on port %d', self.port) - def __enter__(self) -> 'Webserver': + def __enter__(self) -> Self: self.start() return self def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc: Optional[BaseException], traceback: Optional[TracebackType], ) -> Literal[True]: self._web_server.stop_accepting() self._web_server.stop() - logger.debug(f'stopped webserver on port {self.port}') + logger.debug('stopped webserver on port %d', self.port) return True