diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 99e246818..9ecfabe4c 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -38,7 +38,7 @@ jobs: # Skip terraform_tflint which interferes to commit pre-commit auto-fixes - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: - python-version: '3.9' + python-version: '3.10' - name: Execute pre-commit uses: pre-commit/action@9b88afc9cd57fd75b655d5c71bd38146d07135fe # v2.0.3 env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75a649ea1..844a17a94 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -104,7 +104,7 @@ repos: # Dockerfile linter - repo: https://github.com/hadolint/hadolint - rev: v2.12.1-beta + rev: v2.13.1-beta hooks: - id: hadolint args: [ @@ -125,3 +125,94 @@ repos: - id: prettier # https://prettier.io/docs/en/options.html#parser files: '.json5$' + + +########## +# PYTHON # +########## + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + args: [--config, format.quote-style = 'single', --config, line-length = 100] + +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort + args: [--force-single-line, --profile=black] + exclude: | + (?x) + # Uses incorrect indentation in imports in places where it shouldn't + # https://github.com/PyCQA/isort/issues/2315#issuecomment-2566703698 + (^tests/pytest/test__cli_subcommands\.py$ + ) + +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma + +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v2.0.4 + hooks: + - id: autopep8 + args: + - -i + - --max-line-length=100 + +# Usage: http://pylint.pycqa.org/en/latest/user_guide/message-control.html +- repo: https://github.com/PyCQA/pylint + rev: v3.3.3 + hooks: + - id: pylint + args: + - --disable=import-error # E0401. Locally you could not have all imports. + - --disable=fixme # W0511. 'TODO' notations. + - --disable=logging-fstring-interpolation # Conflict with "use a single formatting" WPS323 + - --disable=ungrouped-imports # ignore `if TYPE_CHECKING` case. Other do reorder-python-imports + - --disable=R0801 # Similar lines in 2 files. Currently I don't think that it possible to DRY hooks unique files boilerplate + + exclude: test_.+\.py$ + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + additional_dependencies: + - types-PyYAML + args: [ + --ignore-missing-imports, + --disallow-untyped-calls, + --warn-redundant-casts, + ] + +- repo: https://github.com/wemake-services/wemake-python-styleguide + rev: 1.0.0 + hooks: + - id: wemake-python-styleguide + args: + - --allowed-module-metadata=__all__ # Default to '' + - --max-local-variables=6 # Default to 5 + - --max-returns=6 # Default to 5 + - --max-imports=15 # Default to 12 + # https://wemake-python-stylegui.de/en/latest/pages/usage/violations/index.html + - --extend-ignore= + E501 + WPS115 + WPS226 + + + exclude: | + (?x) + # Ignore tests + (^tests/pytest/test_.+\.py$ + # Ignore deprecated hook + |^src/pre_commit_terraform/terraform_docs_replace\.py$ + ) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 9b8a0cf19..8c4877a6e 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -37,7 +37,7 @@ name: Terraform docs (overwrite README.md) description: Overwrite content of README.md with terraform-docs. require_serial: true - entry: python -Im pre_commit_terraform replace-docs + entry: python -Im pre_commit_terraform terraform_docs_replace language: python files: (\.tf)$ exclude: \.terraform/.*$ diff --git a/.vscode/settings.json b/.vscode/settings.json index c1dc51901..caa4801a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,4 +7,7 @@ "code-block-style": false }, "markdown.validate.enabled": true, + "python.analysis.extraPaths": [ + "./src" + ], } diff --git a/hatch.toml b/hatch.toml index 9d3cd73ea..58a3796e5 100644 --- a/hatch.toml +++ b/hatch.toml @@ -1,5 +1,6 @@ [build.targets.sdist] include = [ + '.pre-commit-hooks.yaml', '.codecov.yml', '.coveragerc', 'src/', @@ -13,6 +14,9 @@ packages = [ 'src/pre_commit_terraform/', ] +[build.targets.wheel.force-include] +'.pre-commit-hooks.yaml' = 'pre_commit_terraform/_artifacts/.pre-commit-hooks.yaml' + [metadata.hooks.vcs.urls] 'Source Archive' = 'https://github.com/antonbabenko/pre-commit-terraform/archive/{commit_hash}.tar.gz' 'GitHub: repo' = 'https://github.com/antonbabenko/pre-commit-terraform' diff --git a/pyproject.toml b/pyproject.toml index c8ae73136..3b22f27e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,10 @@ classifiers = [ 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] -description = 'Pre-commit hooks for terraform_docs_replace' -dependencies = [] +description='Pre-commit hooks for terraform' +dependencies = [ + 'pyyaml', +] dynamic = [ 'urls', 'version', diff --git a/src/pre_commit_terraform/__main__.py b/src/pre_commit_terraform/__main__.py index 18a63dfd0..da387f5bd 100644 --- a/src/pre_commit_terraform/__main__.py +++ b/src/pre_commit_terraform/__main__.py @@ -1,9 +1,9 @@ """A runpy-style CLI entry-point module.""" -from sys import argv, exit as exit_with_return_code - -from ._cli import invoke_cli_app +from sys import argv +from sys import exit as exit_with_return_code +from pre_commit_terraform._cli import invoke_cli_app return_code = invoke_cli_app(argv[1:]) exit_with_return_code(return_code) diff --git a/src/pre_commit_terraform/_cli.py b/src/pre_commit_terraform/_cli.py index edcb2ea30..e05539fd1 100644 --- a/src/pre_commit_terraform/_cli.py +++ b/src/pre_commit_terraform/_cli.py @@ -4,13 +4,12 @@ from typing import cast as cast_to from ._cli_parsing import initialize_argument_parser -from ._errors import ( - PreCommitTerraformBaseError, - PreCommitTerraformExit, - PreCommitTerraformRuntimeError, -) +from ._errors import PreCommitTerraformBaseError +from ._errors import PreCommitTerraformExit +from ._errors import PreCommitTerraformRuntimeError from ._structs import ReturnCode -from ._types import CLIAppEntryPointCallableType, ReturnCodeType +from ._types import CLIAppEntryPointCallableType +from ._types import ReturnCodeType def invoke_cli_app(cli_args: list[str]) -> ReturnCodeType: @@ -34,8 +33,7 @@ def invoke_cli_app(cli_args: list[str]) -> ReturnCodeType: raise except PreCommitTerraformRuntimeError as unhandled_exc: print( - f'App execution took an unexpected turn: {unhandled_exc !s}. ' - 'Exiting...', + f'App execution took an unexpected turn: {unhandled_exc !s}. ' 'Exiting...', file=sys.stderr, ) return ReturnCode.ERROR diff --git a/src/pre_commit_terraform/_cli_parsing.py b/src/pre_commit_terraform/_cli_parsing.py index 969b0ae17..3beb32f47 100644 --- a/src/pre_commit_terraform/_cli_parsing.py +++ b/src/pre_commit_terraform/_cli_parsing.py @@ -6,7 +6,50 @@ from argparse import ArgumentParser -from ._cli_subcommands import SUBCOMMAND_MODULES +from pre_commit_terraform._cli_subcommands import SUBCOMMAND_MODULES + + +def populate_common_argument_parser(parser: ArgumentParser) -> None: + """ + Populate the argument parser with the common arguments. + + Args: + parser (argparse.ArgumentParser): The argument parser to populate. + """ + parser.add_argument( + '-a', + '--args', + action='append', + help='Arguments that configure wrapped tool behavior', + default=[], + ) + parser.add_argument( + '-h', + '--hook-config', + action='append', + metavar='KEY=VALUE', + help='Arguments that configure hook behavior', + default=[], + ) + parser.add_argument( + '-i', + '--tf-init-args', + '--init-args', + action='append', + help='Arguments for `tf init` command', + default=[], + ) + parser.add_argument( + '-e', + '--env-vars', + '--envs', + dest='env_vars_strs', + metavar='KEY=VALUE', + action='append', + help='Setup additional Environment Variables during hook execution', + default=[], + ) + parser.add_argument('files', nargs='*', help='Changed files paths') def attach_subcommand_parsers_to(root_cli_parser: ArgumentParser, /) -> None: @@ -18,19 +61,27 @@ def attach_subcommand_parsers_to(root_cli_parser: ArgumentParser, /) -> None: """ subcommand_parsers = root_cli_parser.add_subparsers( dest='check_name', - help='A check to be performed.', required=True, ) for subcommand_module in SUBCOMMAND_MODULES: - subcommand_parser = subcommand_parsers.add_parser(subcommand_module.CLI_SUBCOMMAND_NAME) + subcommand_parser = subcommand_parsers.add_parser( + subcommand_module.HOOK_ID, + add_help=False, + ) subcommand_parser.set_defaults( invoke_cli_app=subcommand_module.invoke_cli_app, ) - subcommand_module.populate_argument_parser(subcommand_parser) + populate_common_argument_parser(subcommand_parser) + subcommand_module.populate_hook_specific_argument_parser(subcommand_parser) def initialize_argument_parser() -> ArgumentParser: - """Return the root argument parser with sub-commands.""" + """ + Parse the command line arguments and return the parsed arguments. + + Return the root argument parser with sub-commands. + + """ root_cli_parser = ArgumentParser(prog=f'python -m {__package__ !s}') attach_subcommand_parsers_to(root_cli_parser) return root_cli_parser diff --git a/src/pre_commit_terraform/_cli_subcommands.py b/src/pre_commit_terraform/_cli_subcommands.py index fc268e552..3fe5e9404 100644 --- a/src/pre_commit_terraform/_cli_subcommands.py +++ b/src/pre_commit_terraform/_cli_subcommands.py @@ -1,12 +1,9 @@ """A CLI sub-commands organization module.""" -from . import terraform_docs_replace -from ._types import CLISubcommandModuleProtocol +from pre_commit_terraform import terraform_docs_replace +from pre_commit_terraform._types import CLISubcommandModuleProtocol - -SUBCOMMAND_MODULES: list[CLISubcommandModuleProtocol] = [ - terraform_docs_replace, -] +SUBCOMMAND_MODULES: tuple[CLISubcommandModuleProtocol, ...] = (terraform_docs_replace,) __all__ = ('SUBCOMMAND_MODULES',) diff --git a/src/pre_commit_terraform/_common.py b/src/pre_commit_terraform/_common.py new file mode 100644 index 000000000..3b443e6af --- /dev/null +++ b/src/pre_commit_terraform/_common.py @@ -0,0 +1,178 @@ +""" +Common functions for hooks. + +These are not executed directly, but imported by other hooks. +""" + +from __future__ import annotations + +import logging +import os +import shutil +from typing import Callable + +logger = logging.getLogger(__name__) + + +def parse_env_vars(env_var_strs: list[str]) -> dict[str, str]: + """ + Expand environment variables definition into their values in '--args'. + + Args: + env_var_strs (list[str]): A list of environment variable strings in the format "name=value". + + Returns: + dict[str, str]: A dictionary mapping variable names to their corresponding values. + """ + env_var_dict = {} + + for env_var_str in env_var_strs: + name, value = env_var_str.split('=', 1) # noqa: WPS110 # 'value' is valid var name here + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] # noqa: WPS110 # 'value' is valid var name here + env_var_dict[name] = value + + return env_var_dict + + +def _get_unique_dirs(files: list[str]) -> set[str]: + """ + Get unique directories from a list of files. + + Args: + files: list of file paths. + + Returns: + Set of unique directories. + """ + return set(os.path.dirname(path) for path in files) + + +def expand_env_vars(args: list[str], env_vars: dict[str, str]) -> list[str]: + """ + Expand environment variables definition into their values in '--args'. + + Supports expansion only for ${ENV_VAR} vars, not $ENV_VAR. + + Args: + args: The arguments to expand environment variables in. + env_vars: The environment variables to expand. + + Returns: + The arguments with expanded environment variables. + """ + expanded_args = [] + + for arg in args: + for env_var_name, env_var_value in env_vars.items(): + if f'${{{env_var_name}}}' in arg: + logger.info('Expanding ${%s} in "%s"', env_var_name, arg) + arg = arg.replace(f'${{{env_var_name}}}', env_var_value) + logger.debug('After ${%s} expansion: "%s"', env_var_name, arg) + + expanded_args.append(arg) + + return expanded_args + + +def per_dir_hook( + hook_config: list[str], + files: list[str], + args: list[str], + env_vars: dict[str, str], + per_dir_hook_unique_part: Callable[[str, str, list[str], dict[str, str]], int], # noqa: WPS221 +) -> int: + """ + Run hook boilerplate logic which is common to hooks, that run on per dir basis. + + Args: + hook_config: Arguments that configure hook behavior. + files: The list of files to run the hook against. + args: The arguments to pass to the hook. + env_vars: The environment variables to pass to the hook. + per_dir_hook_unique_part: Function with unique part that is specific to running hook. + + Returns: + The exit code of the hook execution for all directories. + """ + # consume modified files passed from pre-commit so that + # hook runs against only those relevant directories + unique_dirs = _get_unique_dirs(files) + + tf_path = get_tf_binary_path(hook_config) + + logger.debug( + 'Iterate per_dir_hook_unique_part with values:' + + '\ntf_path: %s\nunique_dirs: %r\nargs: %r\nenv_vars: %r', + tf_path, + unique_dirs, + args, + env_vars, + ) + final_exit_code = 0 + for dir_path in unique_dirs: + exit_code = per_dir_hook_unique_part(tf_path, dir_path, args, env_vars) + + if exit_code != 0: + final_exit_code = exit_code + + return final_exit_code + + +class BinaryNotFoundError(Exception): + """Exception raised when neither Terraform nor OpenTofu binary could be found.""" + + +def get_tf_binary_path(hook_config: list[str]) -> str: + """ + Get Terraform/OpenTofu binary path. + + Allows user to set the path to custom Terraform or OpenTofu binary. + + Args: + hook_config (list[str]): Arguments that configure hook behavior. + + Environment Variables: + PCT_TFPATH: Path to Terraform or OpenTofu binary. + TERRAGRUNT_TFPATH: Path to Terraform or OpenTofu binary provided by Terragrunt. + + Returns: + str: The path to the Terraform or OpenTofu binary. + + Raises: + BinaryNotFoundError: If neither Terraform nor OpenTofu binary could be found. + + """ + + # direct hook config, has the highest precedence + for config in hook_config: + if config.startswith('--tf-path='): + hook_config_tf_path = config.split('=', 1)[1].rstrip(';') + return hook_config_tf_path + + # environment variable + pct_tfpath = os.getenv('PCT_TFPATH') + if pct_tfpath: + return pct_tfpath + + # Maybe there is a similar setting for Terragrunt already + terragrunt_tfpath = os.getenv('TERRAGRUNT_TFPATH') + if terragrunt_tfpath: + return terragrunt_tfpath + + # check if Terraform binary is available + terraform_path = shutil.which('terraform') + if terraform_path: + return terraform_path + + # finally, check if Tofu binary is available + tofu_path = shutil.which('tofu') + if tofu_path: + return tofu_path + + # If no binary is found, raise an exception + raise BinaryNotFoundError( + 'Neither Terraform nor OpenTofu binary could be found. Please either set the "--tf-path"' + + ' hook configuration argument, or set the "PCT_TFPATH" environment variable, or set the' + + ' "TERRAGRUNT_TFPATH" environment variable, or install Terraform or OpenTofu globally.', + ) diff --git a/src/pre_commit_terraform/_errors.py b/src/pre_commit_terraform/_errors.py index c0f973acc..6f0f20e99 100644 --- a/src/pre_commit_terraform/_errors.py +++ b/src/pre_commit_terraform/_errors.py @@ -5,10 +5,7 @@ class PreCommitTerraformBaseError(Exception): """Base exception for all the in-app errors.""" -class PreCommitTerraformRuntimeError( - PreCommitTerraformBaseError, - RuntimeError, -): +class PreCommitTerraformRuntimeError(PreCommitTerraformBaseError, RuntimeError): """An exception representing a runtime error condition.""" diff --git a/src/pre_commit_terraform/_logger.py b/src/pre_commit_terraform/_logger.py new file mode 100644 index 000000000..9944770a7 --- /dev/null +++ b/src/pre_commit_terraform/_logger.py @@ -0,0 +1,87 @@ +"""Logs-related functions.""" + +import logging +import os +from copy import copy + + +class ColoredFormatter(logging.Formatter): + """A logging formatter that adds color to the log messages.""" + + def __init__(self, pattern: str) -> None: + """ + Initialize the formatter with the given pattern. + + Args: + pattern (str): The log message format pattern. + """ + super().__init__(pattern) + self.disable_color = os.environ.get('PRE_COMMIT_COLOR') == 'never' + + def format(self, record: logging.LogRecord) -> str: + """ + Format the log record and add color to the levelname. + + Args: + record (logging.LogRecord): The log record to format. + + Returns: + str: The formatted log message. + """ + if self.disable_color: + return super().format(record) + + color_mapping = { + 'DEBUG': 37, # white + 'INFO': 36, # cyan + 'WARNING': 33, # yellow + 'ERROR': 31, # red + 'CRITICAL': 41, # white on red background + } + + colored_record = copy(record) + levelname = colored_record.levelname + + set_color = f'\033[{color_mapping.get(levelname, 37)}' # default white # noqa: WPS432 + reset_color = '\033[0m' + colored_record.levelname = f'{set_color}m{levelname}{reset_color}' + + return super().format(colored_record) + + +def setup_logging() -> None: + """ + Set up the logging configuration based on the value of the 'PCT_LOG' environment variable. + + The 'PCT_LOG' environment variable determines the logging level to be used. + The available levels are: + - 'error': Only log error messages. + - 'warn' or 'warning': Log warning messages and above. + - 'info': Log informational messages and above. + - 'debug': Log debug messages and above. + + If the 'PCT_LOG' environment variable is not set or has an invalid value, + the default logging level is 'warning'. + """ + log_level = { + 'error': logging.ERROR, + 'warn': logging.WARNING, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + }[os.environ.get('PCT_LOG', 'warning').lower()] + + log_format = '%(levelname)s:%(funcName)s:%(message)s' + if log_level == logging.DEBUG: + log_format = ( + '\n%(levelname)s:\t%(asctime)s.%(msecs)03d %(filename)s:%(lineno)s -> %(funcName)s()' + + '\n%(message)s' + ) + + formatter = ColoredFormatter(log_format) + log_handler = logging.StreamHandler() + log_handler.setFormatter(formatter) + + log = logging.getLogger() + log.setLevel(log_level) + log.addHandler(log_handler) diff --git a/src/pre_commit_terraform/_run_on_whole_repo.py b/src/pre_commit_terraform/_run_on_whole_repo.py new file mode 100644 index 000000000..397a0d7f2 --- /dev/null +++ b/src/pre_commit_terraform/_run_on_whole_repo.py @@ -0,0 +1,102 @@ +"""Common functions to check if a hook is run on the whole repository.""" + +import logging +import re +import subprocess +from importlib.resources import files as access_artifacts_of + +import yaml + +logger = logging.getLogger(__name__) + + +def is_function_defined(func_name: str, scope: dict) -> bool: + """ + Check if a function is defined in the global scope. + + Args: + scope (dict): The scope (usually globals()) to check in. + func_name (str): The name of the function to check. + + Returns: + bool: True if the function is defined, False otherwise. + """ + is_defined = func_name in scope + is_callable = callable(scope[func_name]) if is_defined else False + + logger.debug( + 'Checking if "%s":\n1. Defined in hook: %s\n2. Is callable: %s', + func_name, + is_defined, + is_callable, + ) + + return is_defined and is_callable + + +def is_hook_run_on_whole_repo(hook_id: str, file_paths: list[str]) -> bool: + """ + Check if the hook is run on the whole repository. + + Args: + hook_id (str): The ID of the hook. + file_paths: The list of files paths. + + Returns: + bool: True if the hook is run on the whole repository, False otherwise. + + Raises: + ValueError: If the hook ID is not found in the .pre-commit-hooks.yaml file. + """ + logger.debug('Hook ID: %s', hook_id) + + # Get the directory containing the packaged `.pre-commit-hooks.yaml` copy + artifacts_root_path = access_artifacts_of('pre_commit_terraform') / '_artifacts' + pre_commit_hooks_yaml_path = artifacts_root_path / '.pre-commit-hooks.yaml' + + logger.debug('Hook config path: %s', pre_commit_hooks_yaml_path) + + # Read the .pre-commit-hooks.yaml file + pre_commit_hooks_yaml_txt = pre_commit_hooks_yaml_path.read_text(encoding='utf-8') + hooks_config = yaml.safe_load(pre_commit_hooks_yaml_txt) + + # Get the included and excluded file patterns for the given hook_id + for hook in hooks_config: + if hook['id'] == hook_id: + included_pattern = re.compile(hook.get('files', '')) + excluded_pattern = re.compile(hook.get('exclude', '')) + break + else: + raise ValueError(f'Hook ID "{hook_id}" not found in .pre-commit-hooks.yaml') + + logger.debug( + 'Included files pattern: %s\nExcluded files pattern: %s', + included_pattern, + excluded_pattern, + ) + # S607 disabled as we need to maintain ability to call git command no matter where it is located. + git_ls_files_cmd = ['git', 'ls-files'] # noqa: S607 + # Get the sorted list of all files that can be checked using `git ls-files` + git_ls_file_paths = subprocess.check_output(git_ls_files_cmd, text=True).splitlines() + + if excluded_pattern: + all_file_paths_that_can_be_checked = [ + file_path + for file_path in git_ls_file_paths + if included_pattern.search(file_path) and not excluded_pattern.search(file_path) + ] + else: + all_file_paths_that_can_be_checked = [ + file_path for file_path in git_ls_file_paths if included_pattern.search(file_path) + ] + + # Get the sorted list of files passed to the hook + file_paths_to_check = sorted(file_paths) + logger.debug( + 'Files to check:\n%s\n\nAll files that can be checked:\n%s\n\nAre these lists identical: %s', + file_paths_to_check, + all_file_paths_that_can_be_checked, + file_paths_to_check == all_file_paths_that_can_be_checked, + ) + # Compare the sorted lists of files + return file_paths_to_check == all_file_paths_that_can_be_checked diff --git a/src/pre_commit_terraform/_types.py b/src/pre_commit_terraform/_types.py index 78db357e7..60613ea70 100644 --- a/src/pre_commit_terraform/_types.py +++ b/src/pre_commit_terraform/_types.py @@ -1,25 +1,25 @@ """Composite types for annotating in-project code.""" -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace from collections.abc import Callable -from typing import Protocol, Union - -from ._structs import ReturnCode +from typing import Protocol +from typing import Union +from pre_commit_terraform._structs import ReturnCode ReturnCodeType = Union[ReturnCode, int] # Union instead of pipe for Python 3.9 CLIAppEntryPointCallableType = Callable[[Namespace], ReturnCodeType] +@runtime_checkable class CLISubcommandModuleProtocol(Protocol): """A protocol for the subcommand-implementing module shape.""" - CLI_SUBCOMMAND_NAME: str + HOOK_ID: str """This constant contains a CLI.""" - def populate_argument_parser( - self, subcommand_parser: ArgumentParser, - ) -> None: + def populate_argument_parser(self, subcommand_parser: ArgumentParser) -> None: """Run a module hook for populating the subcommand parser.""" def invoke_cli_app(self, parsed_cli_args: Namespace) -> ReturnCodeType: diff --git a/src/pre_commit_terraform/terraform_docs_replace.py b/src/pre_commit_terraform/terraform_docs_replace.py index cc83a2a7d..aebb05443 100644 --- a/src/pre_commit_terraform/terraform_docs_replace.py +++ b/src/pre_commit_terraform/terraform_docs_replace.py @@ -1,31 +1,47 @@ +"""Deprecated hook. Don't use it.""" + import os import subprocess import warnings -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace +from typing import Final from typing import cast as cast_to -from ._structs import ReturnCode -from ._types import ReturnCodeType +from pre_commit_terraform._structs import ReturnCode +from pre_commit_terraform._types import ReturnCodeType + +HOOK_ID: Final[str] = __name__.rpartition('.')[-1] -CLI_SUBCOMMAND_NAME: str = 'replace-docs' +def populate_hook_specific_argument_parser(subcommand_parser: ArgumentParser) -> None: + """ + Populate the argument parser with the hook-specific arguments. + Args: + subcommand_parser: The argument parser to populate. + """ -def populate_argument_parser(subcommand_parser: ArgumentParser) -> None: subcommand_parser.description = ( 'Run terraform-docs on a set of files. Follows the standard ' 'convention of pulling the documentation from main.tf in order to ' 'replace the entire README.md file each time.' ) subcommand_parser.add_argument( - '--dest', dest='dest', default='README.md', + '--dest', + dest='dest', + default='README.md', ) subcommand_parser.add_argument( - '--sort-inputs-by-required', dest='sort', action='store_true', + '--sort-inputs-by-required', + dest='sort', + action='store_true', help='[deprecated] use --sort-by-required instead', ) subcommand_parser.add_argument( - '--sort-by-required', dest='sort', action='store_true', + '--sort-by-required', + dest='sort', + action='store_true', ) subcommand_parser.add_argument( '--with-aggregate-type-defaults', @@ -33,44 +49,49 @@ def populate_argument_parser(subcommand_parser: ArgumentParser) -> None: action='store_true', help='[deprecated]', ) - subcommand_parser.add_argument( - 'filenames', - nargs='*', - help='Filenames to check.', - ) def invoke_cli_app(parsed_cli_args: Namespace) -> ReturnCodeType: + """ + Execute main pre-commit hook logic. + + Args: + parsed_cli_args: Parsed arguments from CLI. + + Returns: + int: The exit code of the hook. + """ + warnings.warn( '`terraform_docs_replace` hook is DEPRECATED.' - 'For migration instructions see ' - 'https://github.com/antonbabenko/pre-commit-terraform/issues/248' - '#issuecomment-1290829226', + + 'For migration instructions see ' + + 'https://github.com/antonbabenko/pre-commit-terraform/issues/248' + + '#issuecomment-1290829226', category=UserWarning, ) dirs = [] for filename in cast_to(list[str], parsed_cli_args.filenames): - if (os.path.realpath(filename) not in dirs and - (filename.endswith(".tf") or filename.endswith(".tfvars"))): + if os.path.realpath(filename) not in dirs and ( + filename.endswith('.tf') or filename.endswith('.tfvars') + ): dirs.append(os.path.dirname(filename)) retval = ReturnCode.OK - for dir in dirs: + for directory in dirs: try: procArgs = [] procArgs.append('terraform-docs') if cast_to(bool, parsed_cli_args.sort): procArgs.append('--sort-by-required') procArgs.append('md') - procArgs.append("./{dir}".format(dir=dir)) + procArgs.append('./{dir}'.format(dir=dir)) procArgs.append('>') procArgs.append( - './{dir}/{dest}'. - format(dir=dir, dest=cast_to(bool, parsed_cli_args.dest)), + './{dir}/{dest}'.format(dir=dir, dest=cast_to(bool, parsed_cli_args.dest)), ) - subprocess.check_call(" ".join(procArgs), shell=True) + subprocess.check_call(' '.join(procArgs), shell=True) except subprocess.CalledProcessError as e: print(e) retval = ReturnCode.ERROR diff --git a/tests/pytest/_cli_test.py b/tests/pytest/_cli_test.py index 52ea82ab6..c7f85bf46 100644 --- a/tests/pytest/_cli_test.py +++ b/tests/pytest/_cli_test.py @@ -1,19 +1,18 @@ """Tests for the high-level CLI entry point.""" -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace + import pytest from pre_commit_terraform import _cli_parsing as _cli_parsing_mod from pre_commit_terraform._cli import invoke_cli_app -from pre_commit_terraform._errors import ( - PreCommitTerraformExit, - PreCommitTerraformBaseError, - PreCommitTerraformRuntimeError, -) +from pre_commit_terraform._errors import PreCommitTerraformBaseError +from pre_commit_terraform._errors import PreCommitTerraformExit +from pre_commit_terraform._errors import PreCommitTerraformRuntimeError from pre_commit_terraform._structs import ReturnCode from pre_commit_terraform._types import ReturnCodeType - pytestmark = pytest.mark.filterwarnings( 'ignore:`terraform_docs_replace` hook is DEPRECATED.:UserWarning:' 'pre_commit_terraform.terraform_docs_replace', @@ -42,17 +41,19 @@ ), ) def test_known_interrupts( - capsys: pytest.CaptureFixture[str], - expected_stderr: str, - monkeypatch: pytest.MonkeyPatch, - raised_error: BaseException, + capsys: pytest.CaptureFixture[str], + expected_stderr: str, + monkeypatch: pytest.MonkeyPatch, + raised_error: BaseException, ) -> None: """Check that known interrupts are turned into return code 1.""" + class CustomCmdStub: CLI_SUBCOMMAND_NAME = 'sentinel' def populate_argument_parser( - self, subcommand_parser: ArgumentParser, + self, + subcommand_parser: ArgumentParser, ) -> None: return None @@ -72,15 +73,17 @@ def invoke_cli_app(self, parsed_cli_args: Namespace) -> ReturnCodeType: def test_app_exit( - capsys: pytest.CaptureFixture[str], - monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, ) -> None: """Check that an exit exception is re-raised.""" + class CustomCmdStub: CLI_SUBCOMMAND_NAME = 'sentinel' def populate_argument_parser( - self, subcommand_parser: ArgumentParser, + self, + subcommand_parser: ArgumentParser, ) -> None: return None diff --git a/tests/pytest/terraform_docs_replace_test.py b/tests/pytest/terraform_docs_replace_test.py index 87989d965..0a66d8e81 100644 --- a/tests/pytest/terraform_docs_replace_test.py +++ b/tests/pytest/terraform_docs_replace_test.py @@ -1,16 +1,17 @@ """Tests for the `replace-docs` subcommand.""" -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace from subprocess import CalledProcessError import pytest import pytest_mock from pre_commit_terraform._structs import ReturnCode +from pre_commit_terraform.terraform_docs_replace import invoke_cli_app +from pre_commit_terraform.terraform_docs_replace import populate_argument_parser from pre_commit_terraform.terraform_docs_replace import ( - invoke_cli_app, - populate_argument_parser, - subprocess as replace_docs_subprocess_mod, + subprocess as replace_docs_subprocess_mod, ) @@ -23,10 +24,7 @@ def test_arg_parser_populated() -> None: def test_check_is_deprecated() -> None: """Verify that `replace-docs` shows a deprecation warning.""" - deprecation_msg_regex = ( - r'^`terraform_docs_replace` hook is DEPRECATED\.' - 'For migration.*$' - ) + deprecation_msg_regex = r'^`terraform_docs_replace` hook is DEPRECATED\.' 'For migration.*$' with pytest.warns(UserWarning, match=deprecation_msg_regex): # not `pytest.deprecated_call()` due to this being a user warning invoke_cli_app(Namespace(filenames=[])) @@ -53,8 +51,7 @@ def test_check_is_deprecated() -> None: ), [ 'terraform-docs --sort-by-required md ./ > .//SENTINEL.md', - 'terraform-docs --sort-by-required md ./thing ' - '> ./thing/SENTINEL.md', + 'terraform-docs --sort-by-required md ./thing ' '> ./thing/SENTINEL.md', ], id='two-sorted-files', ), @@ -70,10 +67,10 @@ def test_check_is_deprecated() -> None: 'pre_commit_terraform.terraform_docs_replace', ) def test_control_flow_positive( - expected_cmds: list[str], - mocker: pytest_mock.MockerFixture, - monkeypatch: pytest.MonkeyPatch, - parsed_cli_args: Namespace, + expected_cmds: list[str], + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch, + parsed_cli_args: Namespace, ) -> None: """Check that the subcommand's happy path works.""" check_call_mock = mocker.Mock() @@ -85,9 +82,7 @@ def test_control_flow_positive( assert ReturnCode.OK == invoke_cli_app(parsed_cli_args) - executed_commands = [ - cmd for ((cmd, ), _shell) in check_call_mock.call_args_list - ] + executed_commands = [cmd for ((cmd,), _shell) in check_call_mock.call_args_list] assert len(expected_cmds) == check_call_mock.call_count assert expected_cmds == executed_commands @@ -98,8 +93,8 @@ def test_control_flow_positive( 'pre_commit_terraform.terraform_docs_replace', ) def test_control_flow_negative( - mocker: pytest_mock.MockerFixture, - monkeypatch: pytest.MonkeyPatch, + mocker: pytest_mock.MockerFixture, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Check that the subcommand's error processing works.""" parsed_cli_args = Namespace( diff --git a/tests/pytest/test___main__.py b/tests/pytest/test___main__.py new file mode 100644 index 000000000..3c7dffa55 --- /dev/null +++ b/tests/pytest/test___main__.py @@ -0,0 +1,44 @@ +import importlib +import sys + + +def test_main_success(mocker): + mock_argv = ['__main__.py', 'arg1', 'arg2'] + mock_return_code = 0 + + mocker.patch('sys.argv', mock_argv) + mock_invoke_cli_app = mocker.patch( + 'pre_commit_terraform._cli.invoke_cli_app', + return_value=mock_return_code, + ) + mock_exit = mocker.patch('sys.exit') + + # Reload the module to trigger the main logic + if 'pre_commit_terraform.__main__' in sys.modules: + importlib.reload(sys.modules['pre_commit_terraform.__main__']) + else: + import pre_commit_terraform.__main__ # noqa: F401 + + mock_invoke_cli_app.assert_called_once_with(mock_argv[1:]) + mock_exit.assert_called_once_with(mock_return_code) + + +def test_main_failure(mocker): + mock_argv = ['__main__.py', 'arg1', 'arg2'] + mock_return_code = 1 + + mocker.patch('sys.argv', mock_argv) + mock_invoke_cli_app = mocker.patch( + 'pre_commit_terraform._cli.invoke_cli_app', + return_value=mock_return_code, + ) + mock_exit = mocker.patch('sys.exit') + + # Reload the module to trigger the main logic + if 'pre_commit_terraform.__main__' in sys.modules: + importlib.reload(sys.modules['pre_commit_terraform.__main__']) + else: + import pre_commit_terraform.__main__ # noqa: F401 + + mock_invoke_cli_app.assert_called_once_with(mock_argv[1:]) + mock_exit.assert_called_once_with(mock_return_code) diff --git a/tests/pytest/test__cli.py b/tests/pytest/test__cli.py new file mode 100644 index 000000000..58b303b60 --- /dev/null +++ b/tests/pytest/test__cli.py @@ -0,0 +1,82 @@ +import pytest + +from pre_commit_terraform._cli import invoke_cli_app +from pre_commit_terraform._errors import PreCommitTerraformBaseError +from pre_commit_terraform._errors import PreCommitTerraformExit +from pre_commit_terraform._errors import PreCommitTerraformRuntimeError +from pre_commit_terraform._structs import ReturnCode + + +def test_invoke_cli_app_success(mocker): + mock_parsed_args = mocker.MagicMock() + mock_parsed_args.invoke_cli_app.return_value = ReturnCode.OK + + mock_initialize_argument_parser = mocker.patch( + 'pre_commit_terraform._cli.initialize_argument_parser', + ) + mock_initialize_argument_parser.return_value.parse_args.return_value = mock_parsed_args + + result = invoke_cli_app(['mock_arg']) + + assert result == ReturnCode.OK + mock_parsed_args.invoke_cli_app.assert_called_once_with(mock_parsed_args) + + +def test_invoke_cli_app_pre_commit_terraform_exit(mocker): + mock_parsed_args = mocker.MagicMock() + mock_parsed_args.invoke_cli_app.side_effect = PreCommitTerraformExit('Exit error') + + mock_initialize_argument_parser = mocker.patch( + 'pre_commit_terraform._cli.initialize_argument_parser', + ) + mock_initialize_argument_parser.return_value.parse_args.return_value = mock_parsed_args + + with pytest.raises(PreCommitTerraformExit): + invoke_cli_app(['mock_arg']) + + mock_parsed_args.invoke_cli_app.assert_called_once_with(mock_parsed_args) + + +def test_invoke_cli_app_pre_commit_terraform_runtime_error(mocker): + mock_parsed_args = mocker.MagicMock() + mock_parsed_args.invoke_cli_app.side_effect = PreCommitTerraformRuntimeError('Runtime error') + + mock_initialize_argument_parser = mocker.patch( + 'pre_commit_terraform._cli.initialize_argument_parser', + ) + mock_initialize_argument_parser.return_value.parse_args.return_value = mock_parsed_args + + result = invoke_cli_app(['mock_arg']) + + assert result == ReturnCode.ERROR + mock_parsed_args.invoke_cli_app.assert_called_once_with(mock_parsed_args) + + +def test_invoke_cli_app_pre_commit_terraform_base_error(mocker): + mock_parsed_args = mocker.MagicMock() + mock_parsed_args.invoke_cli_app.side_effect = PreCommitTerraformBaseError('Base error') + + mock_initialize_argument_parser = mocker.patch( + 'pre_commit_terraform._cli.initialize_argument_parser', + ) + mock_initialize_argument_parser.return_value.parse_args.return_value = mock_parsed_args + + result = invoke_cli_app(['mock_arg']) + + assert result == ReturnCode.ERROR + mock_parsed_args.invoke_cli_app.assert_called_once_with(mock_parsed_args) + + +def test_invoke_cli_app_keyboard_interrupt(mocker): + mock_parsed_args = mocker.MagicMock() + mock_parsed_args.invoke_cli_app.side_effect = KeyboardInterrupt('Interrupt') + + mock_initialize_argument_parser = mocker.patch( + 'pre_commit_terraform._cli.initialize_argument_parser', + ) + mock_initialize_argument_parser.return_value.parse_args.return_value = mock_parsed_args + + result = invoke_cli_app(['mock_arg']) + + assert result == ReturnCode.ERROR + mock_parsed_args.invoke_cli_app.assert_called_once_with(mock_parsed_args) diff --git a/tests/pytest/test__cli_parsing.py b/tests/pytest/test__cli_parsing.py new file mode 100644 index 000000000..58ba1ea5e --- /dev/null +++ b/tests/pytest/test__cli_parsing.py @@ -0,0 +1,223 @@ +from argparse import ArgumentParser + +import pytest + +from pre_commit_terraform._cli_parsing import attach_subcommand_parsers_to +from pre_commit_terraform._cli_parsing import initialize_argument_parser +from pre_commit_terraform._cli_parsing import populate_common_argument_parser + + +# ? +# ? populate_common_argument_parser +# ? +def test_populate_common_argument_parser(mocker): + parser = ArgumentParser(add_help=False) + populate_common_argument_parser(parser) + args = parser.parse_args( + ['-a', 'arg1', '-h', 'hook1', '-i', 'init1', '-e', 'env1', 'file1', 'file2'], + ) + + assert args.args == ['arg1'] + assert args.hook_config == ['hook1'] + assert args.tf_init_args == ['init1'] + assert args.env_vars_strs == ['env1'] + assert args.files == ['file1', 'file2'] + + +def test_populate_common_argument_parser_defaults(mocker): + parser = ArgumentParser(add_help=False) + populate_common_argument_parser(parser) + args = parser.parse_args([]) + + assert args.args == [] + assert args.hook_config == [] + assert args.tf_init_args == [] + assert args.env_vars_strs == [] + assert args.files == [] + + +def test_populate_common_argument_parser_multiple_values(mocker): + parser = ArgumentParser(add_help=False) + populate_common_argument_parser(parser) + args = parser.parse_args( + [ + '-a', + 'arg1', + '-a', + 'arg2', + '-h', + 'hook1', + '-h', + 'hook2', + '-i', + 'init1', + '-i', + 'init2', + '-e', + 'env1', + '-e', + 'env2', + 'file1', + 'file2', + ], + ) + + assert args.args == ['arg1', 'arg2'] + assert args.hook_config == ['hook1', 'hook2'] + assert args.tf_init_args == ['init1', 'init2'] + assert args.env_vars_strs == ['env1', 'env2'] + assert args.files == ['file1', 'file2'] + + +# ? +# ? attach_subcommand_parsers_to +# ? +def test_attach_subcommand_parsers_to(mocker): + parser = ArgumentParser(add_help=False) + mock_subcommand_module = mocker.MagicMock() + mock_subcommand_module.HOOK_ID = 'mock_hook' + mock_subcommand_module.invoke_cli_app = mocker.Mock() + mock_subcommand_module.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch('pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', [mock_subcommand_module]) + + attach_subcommand_parsers_to(parser) + + args = parser.parse_args( + ['mock_hook', '-a', 'arg1', '-h', 'hook1', '-i', 'init1', '-e', 'env1', 'file1', 'file2'], + ) + + assert args.check_name == 'mock_hook' + assert args.args == ['arg1'] + assert args.hook_config == ['hook1'] + assert args.tf_init_args == ['init1'] + assert args.env_vars_strs == ['env1'] + assert args.files == ['file1', 'file2'] + assert args.invoke_cli_app == mock_subcommand_module.invoke_cli_app + + mock_subcommand_module.populate_hook_specific_argument_parser.assert_called_once() + + +def test_attach_subcommand_parsers_to_no_args(mocker): + parser = ArgumentParser(add_help=False) + mock_subcommand_module = mocker.MagicMock() + mock_subcommand_module.HOOK_ID = 'mock_hook' + mock_subcommand_module.invoke_cli_app = mocker.Mock() + mock_subcommand_module.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch('pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', [mock_subcommand_module]) + + attach_subcommand_parsers_to(parser) + + with pytest.raises(SystemExit): + parser.parse_args([]) + + +def test_attach_subcommand_parsers_to_multiple_subcommands(mocker): + parser = ArgumentParser(add_help=False) + mock_subcommand_module1 = mocker.MagicMock() + mock_subcommand_module1.HOOK_ID = 'mock_hook1' + mock_subcommand_module1.invoke_cli_app = mocker.Mock() + mock_subcommand_module1.populate_hook_specific_argument_parser = mocker.Mock() + + mock_subcommand_module2 = mocker.MagicMock() + mock_subcommand_module2.HOOK_ID = 'mock_hook2' + mock_subcommand_module2.invoke_cli_app = mocker.Mock() + mock_subcommand_module2.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch( + 'pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', + [mock_subcommand_module1, mock_subcommand_module2], + ) + + attach_subcommand_parsers_to(parser) + + args1 = parser.parse_args(['mock_hook1', '-a', 'arg1']) + assert args1.check_name == 'mock_hook1' + assert args1.args == ['arg1'] + assert args1.invoke_cli_app == mock_subcommand_module1.invoke_cli_app + + args2 = parser.parse_args(['mock_hook2', '-a', 'arg2']) + assert args2.check_name == 'mock_hook2' + assert args2.args == ['arg2'] + assert args2.invoke_cli_app == mock_subcommand_module2.invoke_cli_app + + mock_subcommand_module1.populate_hook_specific_argument_parser.assert_called_once() + mock_subcommand_module2.populate_hook_specific_argument_parser.assert_called_once() + + +# ? +# ? initialize_argument_parser +# ? +def test_initialize_argument_parser(mocker): + mock_subcommand_module = mocker.MagicMock() + mock_subcommand_module.HOOK_ID = 'mock_hook' + mock_subcommand_module.invoke_cli_app = mocker.Mock() + mock_subcommand_module.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch('pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', [mock_subcommand_module]) + + parser = initialize_argument_parser() + assert isinstance(parser, ArgumentParser) + + args = parser.parse_args( + ['mock_hook', '-a', 'arg1', '-h', 'hook1', '-i', 'init1', '-e', 'env1', 'file1', 'file2'], + ) + + assert args.check_name == 'mock_hook' + assert args.args == ['arg1'] + assert args.hook_config == ['hook1'] + assert args.tf_init_args == ['init1'] + assert args.env_vars_strs == ['env1'] + assert args.files == ['file1', 'file2'] + assert args.invoke_cli_app == mock_subcommand_module.invoke_cli_app + + mock_subcommand_module.populate_hook_specific_argument_parser.assert_called_once() + + +def test_initialize_argument_parser_no_args(mocker): + mock_subcommand_module = mocker.MagicMock() + mock_subcommand_module.HOOK_ID = 'mock_hook' + mock_subcommand_module.invoke_cli_app = mocker.Mock() + mock_subcommand_module.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch('pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', [mock_subcommand_module]) + + parser = initialize_argument_parser() + assert isinstance(parser, ArgumentParser) + + with pytest.raises(SystemExit): + parser.parse_args([]) + + +def test_initialize_argument_parser_multiple_subcommands(mocker): + mock_subcommand_module1 = mocker.MagicMock() + mock_subcommand_module1.HOOK_ID = 'mock_hook1' + mock_subcommand_module1.invoke_cli_app = mocker.Mock() + mock_subcommand_module1.populate_hook_specific_argument_parser = mocker.Mock() + + mock_subcommand_module2 = mocker.MagicMock() + mock_subcommand_module2.HOOK_ID = 'mock_hook2' + mock_subcommand_module2.invoke_cli_app = mocker.Mock() + mock_subcommand_module2.populate_hook_specific_argument_parser = mocker.Mock() + + mocker.patch( + 'pre_commit_terraform._cli_parsing.SUBCOMMAND_MODULES', + [mock_subcommand_module1, mock_subcommand_module2], + ) + + parser = initialize_argument_parser() + assert isinstance(parser, ArgumentParser) + + args1 = parser.parse_args(['mock_hook1', '-a', 'arg1']) + assert args1.check_name == 'mock_hook1' + assert args1.args == ['arg1'] + assert args1.invoke_cli_app == mock_subcommand_module1.invoke_cli_app + + args2 = parser.parse_args(['mock_hook2', '-a', 'arg2']) + assert args2.check_name == 'mock_hook2' + assert args2.args == ['arg2'] + assert args2.invoke_cli_app == mock_subcommand_module2.invoke_cli_app + + mock_subcommand_module1.populate_hook_specific_argument_parser.assert_called_once() + mock_subcommand_module2.populate_hook_specific_argument_parser.assert_called_once() diff --git a/tests/pytest/test__cli_subcommands.py b/tests/pytest/test__cli_subcommands.py new file mode 100644 index 000000000..c4771afd7 --- /dev/null +++ b/tests/pytest/test__cli_subcommands.py @@ -0,0 +1,23 @@ +from pre_commit_terraform import terraform_docs_replace +from pre_commit_terraform._cli_subcommands import SUBCOMMAND_MODULES + + +def test_subcommand_modules(mocker): + mock_terraform_docs_replace = mocker.patch('pre_commit_terraform.terraform_docs_replace') + + mock_subcommand_modules = (mock_terraform_docs_replace,) + + mocker.patch( + 'pre_commit_terraform._cli_subcommands.SUBCOMMAND_MODULES', + mock_subcommand_modules, + ) + + from pre_commit_terraform._cli_subcommands import ( + SUBCOMMAND_MODULES as patched_subcommand_modules, + ) + + assert patched_subcommand_modules == mock_subcommand_modules + + +def test_subcommand_modules_content(): + assert terraform_docs_replace in SUBCOMMAND_MODULES diff --git a/tests/pytest/test__common.py b/tests/pytest/test__common.py new file mode 100644 index 000000000..aab34a33d --- /dev/null +++ b/tests/pytest/test__common.py @@ -0,0 +1,275 @@ +# pylint: skip-file +import os +from os.path import join + +import pytest + +from pre_commit_terraform._common import BinaryNotFoundError +from pre_commit_terraform._common import _get_unique_dirs +from pre_commit_terraform._common import expand_env_vars +from pre_commit_terraform._common import get_tf_binary_path +from pre_commit_terraform._common import parse_env_vars + + +# ? +# ? get_unique_dirs +# ? +def test_get_unique_dirs_empty(): + files = [] + result = _get_unique_dirs(files) + assert result == set() + + +def test_get_unique_dirs_single_file(): + files = [join('path', 'to', 'file1.tf')] + result = _get_unique_dirs(files) + assert result == {join('path', 'to')} + + +def test_get_unique_dirs_multiple_files_same_dir(): + files = [join('path', 'to', 'file1.tf'), join('path', 'to', 'file2.tf')] + result = _get_unique_dirs(files) + assert result == {join('path', 'to')} + + +def test_get_unique_dirs_multiple_files_different_dirs(): + files = [join('path', 'to', 'file1.tf'), join('another', 'path', 'file2.tf')] + result = _get_unique_dirs(files) + assert result == {join('path', 'to'), join('another', 'path')} + + +def test_get_unique_dirs_nested_dirs(): + files = [join('path', 'to', 'file1.tf'), join('path', 'to', 'nested', 'file2.tf')] + result = _get_unique_dirs(files) + assert result == {join('path', 'to'), join('path', 'to', 'nested')} + + +# ? +# ? per_dir_hook # TODO: Requires `terraform_fmt` to be implemented +# ? +# @pytest.fixture +# def mock_per_dir_hook_unique_part(mocker): +# return mocker.patch('pre_commit_terraform.terraform_fmt.per_dir_hook_unique_part') + + +# def test_per_dir_hook_empty_files(mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [] +# args = [] +# env_vars = {} +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 0 +# mock_per_dir_hook_unique_part.assert_not_called() + + +# def test_per_dir_hook_single_file(mocker, mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [os.path.join('path', 'to', 'file1.tf')] +# args = [] +# env_vars = {} +# mock_per_dir_hook_unique_part.return_value = 0 +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 0 +# mock_per_dir_hook_unique_part.assert_called_once_with( +# mocker.ANY, # Terraform binary path +# os.path.join('path', 'to'), +# args, +# env_vars, +# ) + + +# def test_per_dir_hook_multiple_files_same_dir(mocker, mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [os.path.join('path', 'to', 'file1.tf'), os.path.join('path', 'to', 'file2.tf')] +# args = [] +# env_vars = {} +# mock_per_dir_hook_unique_part.return_value = 0 +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 0 +# mock_per_dir_hook_unique_part.assert_called_once_with( +# mocker.ANY, # Terraform binary path +# os.path.join('path', 'to'), +# args, +# env_vars, +# ) + + +# def test_per_dir_hook_multiple_files_different_dirs(mocker, mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [os.path.join('path', 'to', 'file1.tf'), os.path.join('another', 'path', 'file2.tf')] +# args = [] +# env_vars = {} +# mock_per_dir_hook_unique_part.return_value = 0 +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 0 +# expected_calls = [ +# mocker.call(mocker.ANY, os.path.join('path', 'to'), args, env_vars), +# mocker.call(mocker.ANY, os.path.join('another', 'path'), args, env_vars), +# ] +# mock_per_dir_hook_unique_part.assert_has_calls(expected_calls, any_order=True) + + +# def test_per_dir_hook_nested_dirs(mocker, mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [ +# os.path.join('path', 'to', 'file1.tf'), +# os.path.join('path', 'to', 'nested', 'file2.tf'), +# ] +# args = [] +# env_vars = {} +# mock_per_dir_hook_unique_part.return_value = 0 +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 0 +# expected_calls = [ +# mocker.call(mocker.ANY, os.path.join('path', 'to'), args, env_vars), +# mocker.call(mocker.ANY, os.path.join('path', 'to', 'nested'), args, env_vars), +# ] +# mock_per_dir_hook_unique_part.assert_has_calls(expected_calls, any_order=True) + + +# def test_per_dir_hook_with_errors(mocker, mock_per_dir_hook_unique_part): +# hook_config = [] +# files = [os.path.join('path', 'to', 'file1.tf'), os.path.join('another', 'path', 'file2.tf')] +# args = [] +# env_vars = {} +# mock_per_dir_hook_unique_part.side_effect = [0, 1] +# result = per_dir_hook(hook_config, files, args, env_vars, mock_per_dir_hook_unique_part) +# assert result == 1 +# expected_calls = [ +# mocker.call(mocker.ANY, os.path.join('path', 'to'), args, env_vars), +# mocker.call(mocker.ANY, os.path.join('another', 'path'), args, env_vars), +# ] +# mock_per_dir_hook_unique_part.assert_has_calls(expected_calls, any_order=True) + + +# ? +# ? parse_env_vars +# ? +def test_parse_env_vars_empty(): + env_var_strs = [] + result = parse_env_vars(env_var_strs) + assert result == {} + + +def test_parse_env_vars_single(): + env_var_strs = ['VAR1=value1'] + result = parse_env_vars(env_var_strs) + assert result == {'VAR1': 'value1'} + + +def test_parse_env_vars_multiple(): + env_var_strs = ['VAR1=value1', 'VAR2=value2'] + result = parse_env_vars(env_var_strs) + assert result == {'VAR1': 'value1', 'VAR2': 'value2'} + + +def test_parse_env_vars_with_quotes(): + env_var_strs = ['VAR1="value1"', 'VAR2="value2"'] + result = parse_env_vars(env_var_strs) + assert result == {'VAR1': 'value1', 'VAR2': 'value2'} + + +def test_parse_env_vars_with_equal_sign_in_value(): + env_var_strs = ['VAR1=value=1', 'VAR2=value=2'] + result = parse_env_vars(env_var_strs) + assert result == {'VAR1': 'value=1', 'VAR2': 'value=2'} + + +def test_parse_env_vars_with_empty_value(): + env_var_strs = ['VAR1=', 'VAR2='] + result = parse_env_vars(env_var_strs) + assert result == {'VAR1': '', 'VAR2': ''} + + +# ? +# ? expand_env_vars +# ? +def test_expand_env_vars_no_vars(): + args = ['arg1', 'arg2'] + env_vars = {} + result = expand_env_vars(args, env_vars) + assert result == ['arg1', 'arg2'] + + +def test_expand_env_vars_single_var(): + args = ['arg1', '${VAR1}', 'arg3'] + env_vars = {'VAR1': 'value1'} + result = expand_env_vars(args, env_vars) + assert result == ['arg1', 'value1', 'arg3'] + + +def test_expand_env_vars_multiple_vars(): + args = ['${VAR1}', 'arg2', '${VAR2}'] + env_vars = {'VAR1': 'value1', 'VAR2': 'value2'} + result = expand_env_vars(args, env_vars) + assert result == ['value1', 'arg2', 'value2'] + + +def test_expand_env_vars_no_expansion(): + args = ['arg1', 'arg2'] + env_vars = {'VAR1': 'value1'} + result = expand_env_vars(args, env_vars) + assert result == ['arg1', 'arg2'] + + +def test_expand_env_vars_partial_expansion(): + args = ['arg1', '${VAR1}', '${VAR2}'] + env_vars = {'VAR1': 'value1'} + result = expand_env_vars(args, env_vars) + assert result == ['arg1', 'value1', '${VAR2}'] + + +def test_expand_env_vars_with_special_chars(): + args = ['arg1', '${VAR_1}', 'arg3'] + env_vars = {'VAR_1': 'value1'} + result = expand_env_vars(args, env_vars) + assert result == ['arg1', 'value1', 'arg3'] + + +# ? +# ? get_tf_binary_path +# ? +def test_get_tf_binary_path_from_hook_config(): + hook_config = ['--tf-path=/custom/path/to/terraform'] + result = get_tf_binary_path(hook_config) + assert result == '/custom/path/to/terraform' + + +def test_get_tf_binary_path_from_pct_tfpath_env_var(mocker): + hook_config = [] + mocker.patch.dict(os.environ, {'PCT_TFPATH': '/env/path/to/terraform'}) + result = get_tf_binary_path(hook_config) + assert result == '/env/path/to/terraform' + + +def test_get_tf_binary_path_from_terragrunt_tfpath_env_var(mocker): + hook_config = [] + mocker.patch.dict(os.environ, {'TERRAGRUNT_TFPATH': '/env/path/to/terragrunt'}) + result = get_tf_binary_path(hook_config) + assert result == '/env/path/to/terragrunt' + + +def test_get_tf_binary_path_from_system_path_terraform(mocker): + hook_config = [] + mocker.patch('shutil.which', return_value='/usr/local/bin/terraform') + result = get_tf_binary_path(hook_config) + assert result == '/usr/local/bin/terraform' + + +def test_get_tf_binary_path_from_system_path_tofu(mocker): + hook_config = [] + mocker.patch('shutil.which', side_effect=[None, '/usr/local/bin/tofu']) + result = get_tf_binary_path(hook_config) + assert result == '/usr/local/bin/tofu' + + +def test_get_tf_binary_path_not_found(mocker): + hook_config = [] + mocker.patch('shutil.which', return_value=None) + with pytest.raises( + BinaryNotFoundError, + match='Neither Terraform nor OpenTofu binary could be found. Please either set the "--tf-path"' + + ' hook configuration argument, or set the "PCT_TFPATH" environment variable, or set the' + + ' "TERRAGRUNT_TFPATH" environment variable, or install Terraform or OpenTofu globally.', + ): + get_tf_binary_path(hook_config) diff --git a/tests/pytest/test__errors.py b/tests/pytest/test__errors.py new file mode 100644 index 000000000..8b8ac3328 --- /dev/null +++ b/tests/pytest/test__errors.py @@ -0,0 +1,20 @@ +import pytest + +from pre_commit_terraform._errors import PreCommitTerraformBaseError +from pre_commit_terraform._errors import PreCommitTerraformExit +from pre_commit_terraform._errors import PreCommitTerraformRuntimeError + + +def test_pre_commit_terraform_base_error(): + with pytest.raises(PreCommitTerraformBaseError): + raise PreCommitTerraformBaseError('Base error occurred') + + +def test_pre_commit_terraform_runtime_error(): + with pytest.raises(PreCommitTerraformRuntimeError): + raise PreCommitTerraformRuntimeError('Runtime error occurred') + + +def test_pre_commit_terraform_exit(): + with pytest.raises(PreCommitTerraformExit): + raise PreCommitTerraformExit('Exit error occurred') diff --git a/tests/pytest/test__run_on_whole_repo.py b/tests/pytest/test__run_on_whole_repo.py new file mode 100644 index 000000000..96e2b6ace --- /dev/null +++ b/tests/pytest/test__run_on_whole_repo.py @@ -0,0 +1,108 @@ +from pathlib import Path + +import pytest +import yaml + +from pre_commit_terraform._run_on_whole_repo import is_function_defined +from pre_commit_terraform._run_on_whole_repo import is_hook_run_on_whole_repo + + +# ? +# ? is_function_defined +# ? +def test_is_function_defined_existing_function(): + def sample_function(): + pass + + scope = globals() + scope['sample_function'] = sample_function + + assert is_function_defined('sample_function', scope) is True + + +def test_is_function_defined_non_existing_function(): + scope = globals() + + assert is_function_defined('non_existing_function', scope) is False + + +def test_is_function_defined_non_callable(): + non_callable = 'I am not a function' + scope = globals() + scope['non_callable'] = non_callable + + assert is_function_defined('non_callable', scope) is False + + +def test_is_function_defined_callable_object(): + class CallableObject: + def __call__(self): + pass + + callable_object = CallableObject() + scope = globals() + scope['callable_object'] = callable_object + + assert is_function_defined('callable_object', scope) is True + + +# ? +# ? is_hook_run_on_whole_repo +# ? +@pytest.fixture +def mock_git_ls_files(): + return [ + 'environment/prd/backends.tf', + 'environment/prd/data.tf', + 'environment/prd/main.tf', + 'environment/prd/outputs.tf', + 'environment/prd/providers.tf', + 'environment/prd/variables.tf', + 'environment/prd/versions.tf', + 'environment/qa/backends.tf', + ] + + +@pytest.fixture +def mock_hooks_config(): + return [{'id': 'example_hook_id', 'files': r'\.tf$', 'exclude': r'\.terraform/.*$'}] + + +def test_is_hook_run_on_whole_repo(mocker, mock_git_ls_files, mock_hooks_config): + # Mock the return value of git ls-files + mocker.patch('subprocess.check_output', return_value='\n'.join(mock_git_ls_files)) + # Mock the return value of reading the .pre-commit-hooks.yaml file + mocker.patch('builtins.open', mocker.mock_open(read_data=yaml.dump(mock_hooks_config))) + # Mock the Path object to return a specific path + mock_path = mocker.patch('pathlib.Path.resolve') + mock_path.return_value.parents.__getitem__.return_value = Path('/mocked/path') + # Mock the read_text method of Path to return the hooks config + mocker.patch('pathlib.Path.read_text', return_value=yaml.dump(mock_hooks_config)) + + # Test case where files match the included pattern and do not match the excluded pattern + files = [ + 'environment/prd/backends.tf', + 'environment/prd/data.tf', + 'environment/prd/main.tf', + 'environment/prd/outputs.tf', + 'environment/prd/providers.tf', + 'environment/prd/variables.tf', + 'environment/prd/versions.tf', + 'environment/qa/backends.tf', + ] + assert is_hook_run_on_whole_repo('example_hook_id', files) is True + + # Test case where files do not match the included pattern + files = ['environment/prd/README.md'] + assert is_hook_run_on_whole_repo('example_hook_id', files) is False + + # Test case where files match the excluded pattern + files = ['environment/prd/.terraform/config.tf'] + assert is_hook_run_on_whole_repo('example_hook_id', files) is False + + # Test case where hook_id is not found + with pytest.raises( + ValueError, + match='Hook ID "non_existing_hook_id" not found in .pre-commit-hooks.yaml', + ): + is_hook_run_on_whole_repo('non_existing_hook_id', files) diff --git a/tests/pytest/test__structs.py b/tests/pytest/test__structs.py new file mode 100644 index 000000000..b0285c475 --- /dev/null +++ b/tests/pytest/test__structs.py @@ -0,0 +1,18 @@ +import pytest + +from pre_commit_terraform._structs import ReturnCode + + +def test_return_code_values(): + assert ReturnCode.OK == 0 + assert ReturnCode.ERROR == 1 + + +def test_return_code_names(): + assert ReturnCode(0).name == 'OK' + assert ReturnCode(1).name == 'ERROR' + + +def test_return_code_invalid_value(): + with pytest.raises(ValueError): + ReturnCode(2) diff --git a/tests/pytest/test__types.py b/tests/pytest/test__types.py new file mode 100644 index 000000000..9dbcfc2e8 --- /dev/null +++ b/tests/pytest/test__types.py @@ -0,0 +1,36 @@ +from argparse import ArgumentParser +from argparse import Namespace + +from pre_commit_terraform._structs import ReturnCode +from pre_commit_terraform._types import CLISubcommandModuleProtocol +from pre_commit_terraform._types import ReturnCodeType + + +def test_return_code_type(): + assert isinstance(ReturnCode.OK, ReturnCodeType) + assert isinstance(ReturnCode.ERROR, ReturnCodeType) + assert isinstance(0, ReturnCodeType) + assert isinstance(1, ReturnCodeType) + assert not isinstance(2.5, ReturnCodeType) + assert not isinstance('string', ReturnCodeType) + + +def test_cli_subcommand_module_protocol(): + class MockSubcommandModule: + HOOK_ID = 'mock_hook' + + def populate_argument_parser(self, subcommand_parser: ArgumentParser) -> None: + pass + + def invoke_cli_app(self, parsed_cli_args: Namespace) -> ReturnCodeType: + return ReturnCode.OK + + assert isinstance(MockSubcommandModule(), CLISubcommandModuleProtocol) + + class InvalidSubcommandModule: + HOOK_ID = 'invalid_hook' + + def populate_argument_parser(self, subcommand_parser: ArgumentParser) -> None: + pass + + assert not isinstance(InvalidSubcommandModule(), CLISubcommandModuleProtocol)