From c2bd81471ac2ee68505932e348563f3489328c8f Mon Sep 17 00:00:00 2001 From: Eugene Pasko Date: Wed, 27 Jul 2022 19:13:59 +0300 Subject: [PATCH] Added exit code returning after tool output --- pre_commit_config_shellcheck.py | 52 +++++++++---- tests/test_pre_commit_config_shellcheck.py | 87 +++++++++++++++++++--- 2 files changed, 115 insertions(+), 24 deletions(-) diff --git a/pre_commit_config_shellcheck.py b/pre_commit_config_shellcheck.py index fd2cdda..09e73a3 100755 --- a/pre_commit_config_shellcheck.py +++ b/pre_commit_config_shellcheck.py @@ -1,7 +1,5 @@ #!/usr/bin/env python - -import os import re import sys import tempfile @@ -70,6 +68,10 @@ def construct_mapping(self, node: Node, deep: bool = False) -> Dict[str, Any]: class PreCommitConfigShellcheck: """Tool for shellchecking pre-commit config files.""" + EXIT_CODE_SUCCESS: int = 0 + EXIT_CODE_ERROR: int = 2 + EXIT_CODE_FILE_NOT_FOUND: int = 5 + def __init__(self): """Get command line args.""" self.options: Namespace = self._get_options() @@ -125,10 +127,10 @@ def _parse_file( ) except FileNotFoundError: sys.stderr.write(f"No file {self.options.path} found\n") - sys.exit(os.EX_OSFILE) + sys.exit(self.EXIT_CODE_FILE_NOT_FOUND) except ScannerError: sys.stderr.write(f"{self.options.path} is not a YAML file\n") - sys.exit(os.EX_IOERR) + sys.exit(self.EXIT_CODE_ERROR) return file_ @@ -161,7 +163,7 @@ def _find_entries( # noqa: CCR001 sys.stderr.write( f"An error happened while checking {self.options.path} file: incorrect format\n" # noqa: E501 ) - sys.exit(os.EX_IOERR) + sys.exit(self.EXIT_CODE_ERROR) return result @@ -183,10 +185,9 @@ def _list_entries( return [] - @staticmethod - def _write_output( - entry: Dict[str, Dict[str, Union[int, str]]], output: str - ) -> None: + def _create_output( + self, entry: Dict[str, Dict[str, Union[int, str]]], output: str + ) -> Tuple[str, int]: """ Edit and write shellcheck output. @@ -194,10 +195,14 @@ def _write_output( :type entry: Dict[str, Dict[str, Union[int, str]]] :param output: base output to edit and process :type output: str + :return: subprocess output with process exit code + :rtype: Tuple[str, int] """ # regular expression for finding line number from output text: # returns two groups: "line #" for output replacement and line number itself regular = re.findall(r"In entry \".*\" (?Pline (?P\d+))", output) + if not regular: + return output, self.EXIT_CODE_SUCCESS for line_number in regular: # subtract 2 because of number of lines difference # in temporary file and source file @@ -208,7 +213,21 @@ def _write_output( line_number[0], f"on line {new_line_number}", ) + + return output, self.EXIT_CODE_ERROR + + @staticmethod + def _write_output(output: str, code: int) -> None: + """ + Write whole tool output and exit with desired code. + + :param output: output to write in console: + :type output: str + :param code: exit code for the tool + :type code: int + """ sys.stdout.write(output) + sys.exit(code) def _check_entry_file( self, @@ -233,7 +252,7 @@ def _check_entry_file( ) except FileNotFoundError: sys.stderr.write(f"No shellcheck found: '{self.options.shellcheck}'\n") - sys.exit(os.EX_OSFILE) + sys.exit(self.EXIT_CODE_FILE_NOT_FOUND) try: stdout, stderr = process.communicate() @@ -242,18 +261,20 @@ def _check_entry_file( sys.stderr.write( f"Failed to check entrypoint {entry['id']['id']} on line {entry['entry']['line']}: {err.stderr}" # noqa: E501 ) - sys.exit(os.EX_IOERR) + sys.exit(self.EXIT_CODE_ERROR) if stderr: sys.stderr.write( f"Failed to check entrypoint {entry['id']['id']} on line {entry['entry']['line']}: {stderr.decode('UTF-8')}" # noqa: E501 ) - sys.exit(os.EX_IOERR) + sys.exit(self.EXIT_CODE_ERROR) return stdout, stderr def _check_entries(self) -> None: """Check the created file for possible entrypoints issues.""" + result = "" + exit_ = self.EXIT_CODE_SUCCESS for entry in self._list_entries(): with tempfile.NamedTemporaryFile("w+") as tmp: tmp.write("#!/bin/sh\n") @@ -264,7 +285,12 @@ def _check_entries(self) -> None: name = f"entry \"{entry['id']['id']}\"" output = stdout.decode("utf-8").replace(tmp.name, name) - self._write_output(entry=entry, output=output) + output, code = self._create_output(entry=entry, output=output) + result += output + if code != self.EXIT_CODE_SUCCESS: + exit_ = code + + self._write_output(output=result, code=exit_) def check(self) -> None: """Check file for entrypoints and verify them.""" diff --git a/tests/test_pre_commit_config_shellcheck.py b/tests/test_pre_commit_config_shellcheck.py index 8e41da4..26f3a4a 100644 --- a/tests/test_pre_commit_config_shellcheck.py +++ b/tests/test_pre_commit_config_shellcheck.py @@ -11,6 +11,8 @@ __all__: List[str] = [ "test_pre_commit_config_shellcheck___list_entries", "test_pre_commit_config_shellcheck___list_entries__bad_format", + "test_pre_commit_config_shellcheck___create_output", + "test_pre_commit_config_shellcheck___write_output__empty", "test_pre_commit_config_shellcheck___write_output", "test_pre_commit_config_shellcheck___check_entries__wrong_shellcheck", "test_pre_commit_config_shellcheck___list_entries__empty", @@ -290,16 +292,12 @@ def test_pre_commit_config_shellcheck___list_entries__empty( assert checker._list_entries() == [] -def test_pre_commit_config_shellcheck___write_output( - mocker: MockerFixture, capsys: CaptureFixture # type: ignore -) -> None: +def test_pre_commit_config_shellcheck___create_output(mocker: MockerFixture) -> None: """ - _check_entries method must write in stdout warning in entrypoint. + _create_output method must return process output and desired exit code. :param mocker: mock :type mocker: MockerFixture - :param capsys: std output fixture - :type capsys: CaptureFixture """ mocker.patch( "sys.argv", @@ -325,10 +323,43 @@ def test_pre_commit_config_shellcheck___write_output( """ checker = PreCommitConfigShellcheck() # type: ignore - checker._write_output(entry=entry, output=output) # type: ignore + expected = ( + """ +In entry "removestar" on line 17: +removestar -i ${NAME} + ^-----^ SC2086: Double quote to prevent globbing and word splitting. - captured = capsys.readouterr() - expected = """ +Did you mean: \nremovestar -i "${NAME}" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +""", + checker.EXIT_CODE_ERROR, + ) + + assert checker._create_output(entry=entry, output=output) == expected # type: ignore # noqa: E501 + + +def test_pre_commit_config_shellcheck___write_output( + mocker: MockerFixture, capsys: CaptureFixture # type: ignore +) -> None: + """ + _write_output method must write in stdout all warnings in entrypoints and exit with exit code. + + :param mocker: mock + :type mocker: MockerFixture + :param capsys: std output fixture + :type capsys: CaptureFixture + """ # noqa: E501 + mocker.patch( + "sys.argv", + [ + "pre_commit_config_shellcheck.py", + "tests/fixtures/.pre-commit-config.yaml", + ], + ) + checker = PreCommitConfigShellcheck() # type: ignore + output = """ In entry "removestar" on line 17: removestar -i ${NAME} ^-----^ SC2086: Double quote to prevent globbing and word splitting. @@ -338,7 +369,40 @@ def test_pre_commit_config_shellcheck___write_output( For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... """ - assert captured.out == expected + code = checker.EXIT_CODE_ERROR + with pytest.raises(SystemExit): + checker._write_output(output=output, code=code) + + captured = capsys.readouterr() + assert captured.out == output + + +def test_pre_commit_config_shellcheck___write_output__empty( + mocker: MockerFixture, capsys: CaptureFixture # type: ignore +) -> None: + """ + _write_output method must exit with no errors. + + :param mocker: mock + :type mocker: MockerFixture + :param capsys: std output fixture + :type capsys: CaptureFixture + """ + mocker.patch( + "sys.argv", + [ + "pre_commit_config_shellcheck.py", + "tests/fixtures/.pre-commit-config.yaml", + ], + ) + checker = PreCommitConfigShellcheck() # type: ignore + output = "" + code = 0 + with pytest.raises(SystemExit): + checker._write_output(output=output, code=code) + + captured = capsys.readouterr() + assert captured.out == output def test_pre_commit_config_shellcheck___check_entries( @@ -361,7 +425,8 @@ def test_pre_commit_config_shellcheck___check_entries( ) checker = PreCommitConfigShellcheck() # type: ignore - checker._check_entries() + with pytest.raises(SystemExit): + checker._check_entries() captured = capsys.readouterr() expected = """