diff --git a/setup.cfg b/setup.cfg index 2da2df7..2ba36a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,8 +25,8 @@ python_requires = >=3.9 [options.entry_points] console_scripts = - silence-lint-error = silence_lint_error.silence_lint_error:main - fix-silenced-error = silence_lint_error.fix_silenced_error:main + silence-lint-error = silence_lint_error.cli.silence_lint_error:main + fix-silenced-error = silence_lint_error.cli.fix_silenced_error:main [bdist_wheel] universal = True diff --git a/tests/fix_silenced_error/__init__.py b/silence_lint_error/cli/__init__.py similarity index 100% rename from tests/fix_silenced_error/__init__.py rename to silence_lint_error/cli/__init__.py diff --git a/tests/silence_lint_error/__init__.py b/silence_lint_error/cli/config.py similarity index 100% rename from tests/silence_lint_error/__init__.py rename to silence_lint_error/cli/config.py diff --git a/silence_lint_error/cli/fix_silenced_error.py b/silence_lint_error/cli/fix_silenced_error.py new file mode 100644 index 0000000..6dd69e3 --- /dev/null +++ b/silence_lint_error/cli/fix_silenced_error.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from typing import NamedTuple + +from silence_lint_error.fixing import Fixer +from silence_lint_error.fixing import Linter +from silence_lint_error.linters import fixit +from silence_lint_error.linters import ruff + + +LINTERS: dict[str, type[Linter]] = { + 'fixit': fixit.Fixit, + 'ruff': ruff.Ruff, +} + + +class Context(NamedTuple): + rule_name: str + file_names: list[str] + linter: Linter + + +def _parse_args(argv: Sequence[str] | None) -> Context: + parser = argparse.ArgumentParser( + description=( + 'Fix linting errors by removing ignore/fixme comments ' + 'and running auto-fixes.' + ), + ) + parser.add_argument( + 'linter', choices=LINTERS, + help='The linter to use to fix the errors', + ) + parser.add_argument('rule_name') + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + return Context( + rule_name=args.rule_name, + file_names=args.filenames, + linter=LINTERS[args.linter](), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + rule_name, file_names, linter = _parse_args(argv) + fixer = Fixer(linter) + + print('-> removing comments that silence errors', file=sys.stderr) + changed_files = [] + for filename in file_names: + try: + fixer.unsilence_violations(rule_name=rule_name, filename=filename) + except fixer.NoChangesMade: + continue + else: + print(filename) + changed_files.append(filename) + + if not changed_files: + print('no silenced errors found', file=sys.stderr) + return 0 + + print(f'-> applying auto-fixes with {linter.name}', file=sys.stderr) + ret, message = fixer.apply_fixes(rule_name=rule_name, filenames=changed_files) + print(message, file=sys.stderr) + + return ret + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/silence_lint_error/cli/silence_lint_error.py b/silence_lint_error/cli/silence_lint_error.py new file mode 100644 index 0000000..36dc5f1 --- /dev/null +++ b/silence_lint_error/cli/silence_lint_error.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from typing import NamedTuple + +from silence_lint_error.linters import fixit +from silence_lint_error.linters import flake8 +from silence_lint_error.linters import ruff +from silence_lint_error.linters import semgrep +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Linter +from silence_lint_error.silencing import Silencer + + +LINTERS: dict[str, type[Linter]] = { + 'fixit': fixit.Fixit, + 'fixit-inline': fixit.FixitInline, + 'flake8': flake8.Flake8, + 'ruff': ruff.Ruff, + 'semgrep': semgrep.Semgrep, +} + + +class Context(NamedTuple): + rule_name: str + file_names: list[str] + linter: Linter + + +def _parse_args(argv: Sequence[str] | None) -> Context: + parser = argparse.ArgumentParser( + description='Ignore linting errors by adding ignore/fixme comments.', + ) + parser.add_argument( + 'linter', choices=LINTERS, + help='The linter for which to ignore errors', + ) + parser.add_argument('rule_name') + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + return Context( + rule_name=args.rule_name, + file_names=args.filenames, + linter=LINTERS[args.linter](), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + rule_name, file_names, linter = _parse_args(argv) + silencer = Silencer(linter) + + print(f'-> finding errors with {linter.name}', file=sys.stderr) + try: + violations = silencer.find_violations( + rule_name=rule_name, file_names=file_names, + ) + except ErrorRunningTool as e: + print(f'ERROR: {e.proc.stderr.strip()}', file=sys.stderr) + return e.proc.returncode + except silencer.NoViolationsFound: + print('no errors found', file=sys.stderr) + return 0 + except silencer.MultipleRulesViolated as e: + print( + 'ERROR: errors found for multiple rules:', sorted(e.rule_names), + file=sys.stderr, + ) + return 1 + else: + print(f'found errors in {len(violations)} files', file=sys.stderr) + + print('-> adding comments to silence errors', file=sys.stderr) + ret = 0 + for filename, file_violations in violations.items(): + print(filename) + ret |= silencer.silence_violations( + filename=filename, violations=file_violations, + ) + + return ret + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/silence_lint_error/fix_silenced_error.py b/silence_lint_error/fix_silenced_error.py deleted file mode 100644 index 4ec15ce..0000000 --- a/silence_lint_error/fix_silenced_error.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import argparse -import subprocess -import sys -from collections.abc import Iterator -from collections.abc import Sequence -from typing import NamedTuple -from typing import Protocol -from typing import TYPE_CHECKING - -from . import comments - - -if TYPE_CHECKING: - from typing import TypeAlias - - FileName: TypeAlias = str - RuleName: TypeAlias = str - - -# Linters -# ======= - -class Violation(NamedTuple): - rule_name: RuleName - lineno: int - - -class Linter(Protocol): - name: str - - def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: - ... - - def apply_fixes( - self, rule_name: RuleName, filenames: Sequence[str], - ) -> tuple[int, str]: - ... - - -class Fixit: - name = 'fixit' - - def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: - return ''.join( - self._remove_comments( - src.splitlines(keepends=True), rule_name, - ), - ) - - def _remove_comments( - self, lines: Sequence[str], rule_name: RuleName, - ) -> Iterator[str]: - __, rule_id = rule_name.rsplit(':', maxsplit=1) - fixme_comment = f'# lint-fixme: {rule_id}' - for line in lines: - if line.strip() == fixme_comment: # fixme comment only - continue - elif line.rstrip().endswith(fixme_comment): # code then fixme comment - trailing_ws = line.removeprefix(line.rstrip()) - line_without_comment = ( - line.rstrip().removesuffix(fixme_comment) # remove comment - .rstrip() # and remove any intermediate ws - ) - yield line_without_comment + trailing_ws - else: - yield line - - def apply_fixes( - self, rule_name: RuleName, filenames: Sequence[str], - ) -> tuple[int, str]: - proc = subprocess.run( - ( - sys.executable, '-mfixit', - '--rules', rule_name, - 'fix', '--automatic', *filenames, - ), - capture_output=True, text=True, - ) - return proc.returncode, proc.stderr.strip() - - -class Ruff: - name = 'ruff' - - def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: - return comments.remove_error_silencing_comments( - src, comment_type='noqa', error_code=rule_name, - ) - - def apply_fixes( - self, rule_name: RuleName, filenames: Sequence[str], - ) -> tuple[int, str]: - proc = subprocess.run( - ( - sys.executable, '-mruff', - 'check', '--fix', - '--select', rule_name, - *filenames, - ), - capture_output=True, text=True, - ) - return proc.returncode, proc.stdout.strip() - - -LINTERS: dict[str, type[Linter]] = { - 'fixit': Fixit, - 'ruff': Ruff, -} - - -# CLI -# === - -class Context(NamedTuple): - rule_name: RuleName - file_names: list[FileName] - linter: Linter - - -def _parse_args(argv: Sequence[str] | None) -> Context: - parser = argparse.ArgumentParser( - description=( - 'Fix linting errors by removing ignore/fixme comments ' - 'and running auto-fixes.' - ), - ) - parser.add_argument( - 'linter', choices=LINTERS, - help='The linter to use to fix the errors', - ) - parser.add_argument('rule_name') - parser.add_argument('filenames', nargs='*') - args = parser.parse_args(argv) - - return Context( - rule_name=args.rule_name, - file_names=args.filenames, - linter=LINTERS[args.linter](), - ) - - -class NoChangesMade(Exception): - pass - - -def _unsilence_violations( - linter: Linter, rule_name: RuleName, filename: FileName, -) -> None: - with open(filename) as f: - src = f.read() - - src_without_comments = linter.remove_silence_comments(src, rule_name) - - if src_without_comments == src: - raise NoChangesMade - - with open(filename, 'w') as f: - f.write(src_without_comments) - - -def main(argv: Sequence[str] | None = None) -> int: - rule_name, file_names, linter = _parse_args(argv) - - print('-> removing comments that silence errors', file=sys.stderr) - changed_files = [] - for filename in file_names: - try: - _unsilence_violations(linter, rule_name, filename) - except NoChangesMade: - continue - else: - print(filename) - changed_files.append(filename) - - if not changed_files: - print('no silenced errors found', file=sys.stderr) - return 0 - - print(f'-> applying auto-fixes with {linter.name}', file=sys.stderr) - ret, message = linter.apply_fixes(rule_name, changed_files) - print(message, file=sys.stderr) - - return ret - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/silence_lint_error/fixing.py b/silence_lint_error/fixing.py new file mode 100644 index 0000000..60d6111 --- /dev/null +++ b/silence_lint_error/fixing.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + +import attrs + + +class Linter(Protocol): + name: str + + def remove_silence_comments(self, src: str, rule_name: str) -> str: + """Remove comments that silence rule violations. + + Returns: + Modified `src` without comments that silence the `violations`. + """ + + def apply_fixes( + self, rule_name: str, filenames: Sequence[str], + ) -> tuple[int, str]: + """Fix violations of a rule. + + Returns: + Return code and stdout from the process that fixed the violations. + """ + + +@attrs.frozen +class Fixer: + linter: Linter + + class NoChangesMade(Exception): + pass + + def unsilence_violations( + self, *, rule_name: str, filename: str, + ) -> None: + with open(filename) as f: + src = f.read() + + src_without_comments = self.linter.remove_silence_comments(src, rule_name) + + if src_without_comments == src: + raise self.NoChangesMade + + with open(filename, 'w') as f: + f.write(src_without_comments) + + def apply_fixes( + self, *, rule_name: str, filenames: Sequence[str], + ) -> tuple[int, str]: + return self.linter.apply_fixes(rule_name, filenames) diff --git a/silence_lint_error/linters/__init__.py b/silence_lint_error/linters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/silence_lint_error/linters/fixit.py b/silence_lint_error/linters/fixit.py new file mode 100644 index 0000000..4b47d55 --- /dev/null +++ b/silence_lint_error/linters/fixit.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import re +import subprocess +import sys +from collections import defaultdict +from collections.abc import Iterator +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from silence_lint_error import comments +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Violation + +if TYPE_CHECKING: + from typing import TypeAlias + + FileName: TypeAlias = str + RuleName: TypeAlias = str + + +class Fixit: + name = 'fixit' + + def __init__(self) -> None: + self.error_line_re = re.compile(r'^.*?@\d+:\d+ ') + + def find_violations( + self, rule_name: RuleName, filenames: Sequence[FileName], + ) -> dict[FileName, list[Violation]]: + proc = subprocess.run( + ( + sys.executable, '-mfixit', + '--rules', rule_name, + 'lint', *filenames, + ), + capture_output=True, + text=True, + ) + + if proc.returncode and proc.stderr.endswith('No module named fixit\n'): + raise ErrorRunningTool(proc) + + # extract filenames and line numbers + results: dict[str, list[Violation]] = defaultdict(list) + for line in proc.stdout.splitlines(): + found_error = self._parse_output_line(line) + if found_error: + filename, violation = found_error + results[filename].append(violation) + else: # pragma: no cover + pass + + return results + + def _parse_output_line( + self, line: str, + ) -> tuple[FileName, Violation] | None: + if not self.error_line_re.match(line): + return None + + location, violated_rule_name, *__ = line.split(maxsplit=2) + filename, position = location.split('@', maxsplit=1) + lineno, *__ = position.split(':', maxsplit=1) + + rule_name_ = violated_rule_name.removesuffix(':') + return filename, Violation(rule_name_, int(lineno)) + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + [rule_name] = {violation.rule_name for violation in violations} + linenos_to_silence = {violation.lineno for violation in violations} + + lines = src.splitlines(keepends=True) + + new_lines = [] + for current_lineno, line in enumerate(lines, start=1): + if current_lineno in linenos_to_silence: + leading_ws = line.removesuffix(line.lstrip()) + new_lines.append(f'{leading_ws}# lint-fixme: {rule_name}\n') + new_lines.append(line) + + return ''.join(new_lines) + + def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: + return ''.join( + self._remove_comments( + src.splitlines(keepends=True), rule_name, + ), + ) + + def _remove_comments( + self, lines: Sequence[str], rule_name: RuleName, + ) -> Iterator[str]: + __, rule_id = rule_name.rsplit(':', maxsplit=1) + fixme_comment = f'# lint-fixme: {rule_id}' + for line in lines: + if line.strip() == fixme_comment: # fixme comment only + continue + elif line.rstrip().endswith(fixme_comment): # code then fixme comment + trailing_ws = line.removeprefix(line.rstrip()) + line_without_comment = ( + line.rstrip().removesuffix(fixme_comment) # remove comment + .rstrip() # and remove any intermediate ws + ) + yield line_without_comment + trailing_ws + else: + yield line + + def apply_fixes( + self, rule_name: RuleName, filenames: Sequence[str], + ) -> tuple[int, str]: + proc = subprocess.run( + ( + sys.executable, '-mfixit', + '--rules', rule_name, + 'fix', '--automatic', *filenames, + ), + capture_output=True, text=True, + ) + return proc.returncode, proc.stderr.strip() + + +class FixitInline(Fixit): + """An alternative `fixit` implementation that adds `lint-fixme` comment inline. + + This is sometimes necessary because `fixit` does not always respect `lint-fixme` + comments when they are on the line above the line causing the error. This is a + known bug and is reported in https://github.com/Instagram/Fixit/issues/405. + + In some of these cases, placing the comment on the same line as the error can + ensure it is respected (e.g. for decorators). + """ + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + [rule_name] = {violation.rule_name for violation in violations} + linenos_to_silence = {violation.lineno for violation in violations} + return comments.add_error_silencing_comments( + src, linenos_to_silence, + 'lint-fixme', rule_name, + ) diff --git a/silence_lint_error/linters/flake8.py b/silence_lint_error/linters/flake8.py new file mode 100644 index 0000000..ac97fb8 --- /dev/null +++ b/silence_lint_error/linters/flake8.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import subprocess +import sys +from collections import defaultdict +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from silence_lint_error import comments +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Violation + +if TYPE_CHECKING: + from typing import TypeAlias + + FileName: TypeAlias = str + RuleName: TypeAlias = str + + +class Flake8: + name = 'flake8' + + def find_violations( + self, rule_name: RuleName, filenames: Sequence[FileName], + ) -> dict[FileName, list[Violation]]: + proc = subprocess.run( + ( + sys.executable, '-mflake8', + '--select', rule_name, + '--format', '%(path)s %(row)s', + *filenames, + ), + capture_output=True, + text=True, + ) + + if proc.returncode and proc.stderr.endswith('No module named flake8\n'): + raise ErrorRunningTool(proc) + + # extract filenames and line numbers + results: dict[FileName, list[Violation]] = defaultdict(list) + for line in proc.stdout.splitlines(): + filename_, lineno_ = line.rsplit(maxsplit=1) + results[filename_].append(Violation(rule_name, int(lineno_))) + + return results + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + [rule_name] = {violation.rule_name for violation in violations} + linenos_to_silence = {violation.lineno for violation in violations} + return comments.add_noqa_comments(src, linenos_to_silence, rule_name) diff --git a/silence_lint_error/linters/ruff.py b/silence_lint_error/linters/ruff.py new file mode 100644 index 0000000..5cf3c22 --- /dev/null +++ b/silence_lint_error/linters/ruff.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from collections import defaultdict +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from silence_lint_error import comments +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Violation + +if TYPE_CHECKING: + from typing import TypeAlias + + FileName: TypeAlias = str + RuleName: TypeAlias = str + + +class Ruff: + name = 'ruff' + + def find_violations( + self, rule_name: RuleName, filenames: Sequence[FileName], + ) -> dict[FileName, list[Violation]]: + proc = subprocess.run( + ( + sys.executable, '-mruff', + '--select', rule_name, + '--output-format', 'json', + *filenames, + ), + capture_output=True, + text=True, + ) + + if proc.returncode and proc.stderr.endswith('No module named ruff\n'): + raise ErrorRunningTool(proc) + + # extract filenames and line numbers + all_violations = json.loads(proc.stdout) + results: dict[FileName, list[Violation]] = defaultdict(list) + for violation in all_violations: + results[violation['filename']].append( + Violation( + rule_name=violation['code'], + lineno=violation['location']['row'], + ), + ) + + return results + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + [rule_name] = {violation.rule_name for violation in violations} + linenos_to_silence = {violation.lineno for violation in violations} + return comments.add_noqa_comments(src, linenos_to_silence, rule_name) + + def remove_silence_comments(self, src: str, rule_name: RuleName) -> str: + return comments.remove_error_silencing_comments( + src, comment_type='noqa', error_code=rule_name, + ) + + def apply_fixes( + self, rule_name: RuleName, filenames: Sequence[str], + ) -> tuple[int, str]: + proc = subprocess.run( + ( + sys.executable, '-mruff', + 'check', '--fix', + '--select', rule_name, + *filenames, + ), + capture_output=True, text=True, + ) + return proc.returncode, proc.stdout.strip() diff --git a/silence_lint_error/linters/semgrep.py b/silence_lint_error/linters/semgrep.py new file mode 100644 index 0000000..2d67bf3 --- /dev/null +++ b/silence_lint_error/linters/semgrep.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import json +import subprocess +from collections import defaultdict +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from silence_lint_error.silencing import ErrorRunningTool +from silence_lint_error.silencing import Violation + +if TYPE_CHECKING: + from typing import TypeAlias + + FileName: TypeAlias = str + RuleName: TypeAlias = str + + +class Semgrep: + name = 'semgrep' + + def find_violations( + self, rule_name: RuleName, filenames: Sequence[FileName], + ) -> dict[FileName, list[Violation]]: + proc = subprocess.run( + ( + 'semgrep', 'scan', + '--metrics=off', '--oss-only', + '--json', + *filenames, + ), + capture_output=True, + text=True, + ) + + if proc.returncode: + raise ErrorRunningTool(proc) + + # extract filenames and line numbers + results: dict[FileName, list[Violation]] = defaultdict(list) + data = json.loads(proc.stdout) + for result in data['results']: + if result['check_id'] != rule_name: + continue + + results[result['path']].append( + Violation( + rule_name=result['check_id'], + lineno=result['start']['line'], + ), + ) + + return dict(results) + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + [rule_name] = {violation.rule_name for violation in violations} + linenos_to_silence = {violation.lineno for violation in violations} + + lines = src.splitlines(keepends=True) + + new_lines = [] + for current_lineno, line in enumerate(lines, start=1): + if current_lineno in linenos_to_silence: + leading_ws = line.removesuffix(line.lstrip()) + new_lines.append(f'{leading_ws}# nosemgrep: {rule_name}\n') + new_lines.append(line) + + return ''.join(new_lines) diff --git a/silence_lint_error/silence_lint_error.py b/silence_lint_error/silence_lint_error.py deleted file mode 100644 index b00ba0a..0000000 --- a/silence_lint_error/silence_lint_error.py +++ /dev/null @@ -1,380 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from collections import defaultdict -from collections.abc import Sequence -from typing import NamedTuple -from typing import Protocol -from typing import TYPE_CHECKING - -import attrs - -from . import comments - -if TYPE_CHECKING: - from typing import TypeAlias - - FileName: TypeAlias = str - RuleName: TypeAlias = str - - -# Linters -# ======= - -class Violation(NamedTuple): - rule_name: RuleName - lineno: int - - -@attrs.frozen -class ErrorRunningTool(Exception): - proc: subprocess.CompletedProcess[str] - - -class Linter(Protocol): - name: str - - def find_violations( - self, rule_name: RuleName, filenames: Sequence[FileName], - ) -> dict[FileName, list[Violation]]: - ... - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - ... - - -class Fixit: - name = 'fixit' - - def __init__(self) -> None: - self.error_line_re = re.compile(r'^.*?@\d+:\d+ ') - - def find_violations( - self, rule_name: RuleName, filenames: Sequence[FileName], - ) -> dict[FileName, list[Violation]]: - proc = subprocess.run( - ( - sys.executable, '-mfixit', - '--rules', rule_name, - 'lint', *filenames, - ), - capture_output=True, - text=True, - ) - - if proc.returncode and proc.stderr.endswith('No module named fixit\n'): - raise ErrorRunningTool(proc) - - # extract filenames and line numbers - results: dict[str, list[Violation]] = defaultdict(list) - for line in proc.stdout.splitlines(): - found_error = self._parse_output_line(line) - if found_error: - filename, violation = found_error - results[filename].append(violation) - else: # pragma: no cover - pass - - return results - - def _parse_output_line( - self, line: str, - ) -> tuple[FileName, Violation] | None: - if not self.error_line_re.match(line): - return None - - location, violated_rule_name, *__ = line.split(maxsplit=2) - filename, position = location.split('@', maxsplit=1) - lineno, *__ = position.split(':', maxsplit=1) - - rule_name_ = violated_rule_name.removesuffix(':') - return filename, Violation(rule_name_, int(lineno)) - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - [rule_name] = {violation.rule_name for violation in violations} - linenos_to_silence = {violation.lineno for violation in violations} - - lines = src.splitlines(keepends=True) - - new_lines = [] - for current_lineno, line in enumerate(lines, start=1): - if current_lineno in linenos_to_silence: - leading_ws = line.removesuffix(line.lstrip()) - new_lines.append(f'{leading_ws}# lint-fixme: {rule_name}\n') - new_lines.append(line) - - return ''.join(new_lines) - - -class FixitInline(Fixit): - """An alternative `fixit` implementation that adds `lint-fixme` comment inline. - - This is sometimes necessary because `fixit` does not always respect `lint-fixme` - comments when they are on the line above the line causing the error. This is a - known bug and is reported in https://github.com/Instagram/Fixit/issues/405. - - In some of these cases, placing the comment on the same line as the error can - ensure it is respected (e.g. for decorators). - """ - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - [rule_name] = {violation.rule_name for violation in violations} - linenos_to_silence = {violation.lineno for violation in violations} - return comments.add_error_silencing_comments( - src, linenos_to_silence, - 'lint-fixme', rule_name, - ) - - -class Flake8: - name = 'flake8' - - def find_violations( - self, rule_name: RuleName, filenames: Sequence[FileName], - ) -> dict[FileName, list[Violation]]: - proc = subprocess.run( - ( - sys.executable, '-mflake8', - '--select', rule_name, - '--format', '%(path)s %(row)s', - *filenames, - ), - capture_output=True, - text=True, - ) - - if proc.returncode and proc.stderr.endswith('No module named flake8\n'): - raise ErrorRunningTool(proc) - - # extract filenames and line numbers - results: dict[FileName, list[Violation]] = defaultdict(list) - for line in proc.stdout.splitlines(): - filename_, lineno_ = line.rsplit(maxsplit=1) - results[filename_].append(Violation(rule_name, int(lineno_))) - - return results - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - [rule_name] = {violation.rule_name for violation in violations} - linenos_to_silence = {violation.lineno for violation in violations} - return comments.add_noqa_comments(src, linenos_to_silence, rule_name) - - -class Ruff: - name = 'ruff' - - def find_violations( - self, rule_name: RuleName, filenames: Sequence[FileName], - ) -> dict[FileName, list[Violation]]: - proc = subprocess.run( - ( - sys.executable, '-mruff', - '--select', rule_name, - '--output-format', 'json', - *filenames, - ), - capture_output=True, - text=True, - ) - - if proc.returncode and proc.stderr.endswith('No module named ruff\n'): - raise ErrorRunningTool(proc) - - # extract filenames and line numbers - all_violations = json.loads(proc.stdout) - results: dict[FileName, list[Violation]] = defaultdict(list) - for violation in all_violations: - results[violation['filename']].append( - Violation( - rule_name=violation['code'], - lineno=violation['location']['row'], - ), - ) - - return results - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - [rule_name] = {violation.rule_name for violation in violations} - linenos_to_silence = {violation.lineno for violation in violations} - return comments.add_noqa_comments(src, linenos_to_silence, rule_name) - - -class Semgrep: - name = 'semgrep' - - def find_violations( - self, rule_name: RuleName, filenames: Sequence[FileName], - ) -> dict[FileName, list[Violation]]: - proc = subprocess.run( - ( - 'semgrep', 'scan', - '--metrics=off', '--oss-only', - '--json', - *filenames, - ), - capture_output=True, - text=True, - ) - - if proc.returncode: - raise ErrorRunningTool(proc) - - # extract filenames and line numbers - results: dict[FileName, list[Violation]] = defaultdict(list) - data = json.loads(proc.stdout) - for result in data['results']: - if result['check_id'] != rule_name: - continue - - results[result['path']].append( - Violation( - rule_name=result['check_id'], - lineno=result['start']['line'], - ), - ) - - return dict(results) - - def silence_violations( - self, src: str, violations: Sequence[Violation], - ) -> str: - [rule_name] = {violation.rule_name for violation in violations} - linenos_to_silence = {violation.lineno for violation in violations} - - lines = src.splitlines(keepends=True) - - new_lines = [] - for current_lineno, line in enumerate(lines, start=1): - if current_lineno in linenos_to_silence: - leading_ws = line.removesuffix(line.lstrip()) - new_lines.append(f'{leading_ws}# nosemgrep: {rule_name}\n') - new_lines.append(line) - - return ''.join(new_lines) - - -LINTERS: dict[str, type[Linter]] = { - 'fixit': Fixit, - 'fixit-inline': FixitInline, - 'flake8': Flake8, - 'ruff': Ruff, - 'semgrep': Semgrep, -} - - -# CLI -# === - -class Context(NamedTuple): - rule_name: RuleName - file_names: list[FileName] - linter: Linter - - -def _parse_args(argv: Sequence[str] | None) -> Context: - parser = argparse.ArgumentParser( - description='Ignore linting errors by adding ignore/fixme comments.', - ) - parser.add_argument( - 'linter', choices=LINTERS, - help='The linter for which to ignore errors', - ) - parser.add_argument('rule_name') - parser.add_argument('filenames', nargs='*') - args = parser.parse_args(argv) - - return Context( - rule_name=args.rule_name, - file_names=args.filenames, - linter=LINTERS[args.linter](), - ) - - -class NoViolationsFound(Exception): - pass - - -@attrs.frozen -class MultipleRulesViolated(Exception): - rule_names: set[RuleName] - - -def _find_violations( - linter: Linter, rule_name: RuleName, file_names: Sequence[FileName], -) -> dict[FileName, list[Violation]]: - violations = linter.find_violations(rule_name, file_names) - - if not violations: - raise NoViolationsFound - - violation_names = { - violation.rule_name - for file_violations in violations.values() - for violation in file_violations - } - if len(violation_names) != 1: - raise MultipleRulesViolated(violation_names) - - return violations - - -def _silence_violations( - linter: Linter, filename: FileName, violations: Sequence[Violation], -) -> bool: - with open(filename) as f: - src = f.read() - - src_with_comments = linter.silence_violations(src, violations) - - with open(filename, 'w') as f: - f.write(src_with_comments) - - return src_with_comments != src - - -def main(argv: Sequence[str] | None = None) -> int: - rule_name, file_names, linter = _parse_args(argv) - - print(f'-> finding errors with {linter.name}', file=sys.stderr) - try: - violations = _find_violations(linter, rule_name, file_names) - except ErrorRunningTool as e: - print(f'ERROR: {e.proc.stderr.strip()}', file=sys.stderr) - return e.proc.returncode - except NoViolationsFound: - print('no errors found', file=sys.stderr) - return 0 - except MultipleRulesViolated as e: - print( - 'ERROR: errors found for multiple rules:', sorted(e.rule_names), - file=sys.stderr, - ) - return 1 - else: - print(f'found errors in {len(violations)} files', file=sys.stderr) - - print('-> adding comments to silence errors', file=sys.stderr) - ret = 0 - for filename, file_violations in violations.items(): - print(filename) - ret |= _silence_violations(linter, filename, file_violations) - - return ret - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/silence_lint_error/silencing.py b/silence_lint_error/silencing.py new file mode 100644 index 0000000..5144e7f --- /dev/null +++ b/silence_lint_error/silencing.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Sequence +from typing import Protocol + +import attrs + + +@attrs.frozen +class Violation: + rule_name: str + lineno: int + + +@attrs.frozen +class ErrorRunningTool(Exception): + proc: subprocess.CompletedProcess[str] + + +class Linter(Protocol): + name: str + + def find_violations( + self, rule_name: str, filenames: Sequence[str], + ) -> dict[str, list[Violation]]: + """Find violations of a rule. + + Returns: + Mapping of file path to the violations found in that file. + + Raises: + ErrorRunningTool: There was an error whilst running the linter. + """ + + def silence_violations( + self, src: str, violations: Sequence[Violation], + ) -> str: + """Modify module source to silence violations. + + Returns: + Modified `src` with comments that silence the `violations`. + """ + + +@attrs.frozen +class Silencer: + linter: Linter + + class NoViolationsFound(Exception): + pass + + @attrs.frozen + class MultipleRulesViolated(Exception): + rule_names: set[str] + + def find_violations( + self, *, rule_name: str, file_names: Sequence[str], + ) -> dict[str, list[Violation]]: + violations = self.linter.find_violations(rule_name, file_names) + + if not violations: + raise self.NoViolationsFound + + violation_names = { + violation.rule_name + for file_violations in violations.values() + for violation in file_violations + } + if len(violation_names) != 1: + raise self.MultipleRulesViolated(violation_names) + + return violations + + def silence_violations( + self, *, filename: str, violations: Sequence[Violation], + ) -> bool: + with open(filename) as f: + src = f.read() + + src_with_comments = self.linter.silence_violations(src, violations) + + with open(filename, 'w') as f: + f.write(src_with_comments) + + return src_with_comments != src diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/fix_silenced_error_test.py b/tests/cli/fix_silenced_error_test.py new file mode 100644 index 0000000..0acdf28 --- /dev/null +++ b/tests/cli/fix_silenced_error_test.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from silence_lint_error.cli.fix_silenced_error import main + + +class TestFixit: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + src = """\ +x = None +# lint-fixme: CollapseIsinstanceChecks +isinstance(x, str) or isinstance(x, int) +isinstance(x, bool) or isinstance(x, float) # lint-fixme: CollapseIsinstanceChecks + +def f(x): + # lint-ignore: CollapseIsinstanceChecks + return isinstance(x, str) or isinstance(x, int) +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main( + ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), + ) + + assert ret == 0 + assert python_module.read_text() == """\ +x = None +isinstance(x, (str, int)) +isinstance(x, (bool, float)) + +def f(x): + # lint-ignore: CollapseIsinstanceChecks + return isinstance(x, str) or isinstance(x, int) +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> removing comments that silence errors +-> applying auto-fixes with fixit +🛠️ 1 file checked, 1 file with errors, 2 auto-fixes available, 2 fixes applied 🛠️ +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def foo(): + print('hello there') +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main( + ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), + ) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> removing comments that silence errors +no silenced errors found +""" + + +class TestRuff: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + src = """\ +import math +import os # noqa: F401 +import sys + +print(math.pi, file=sys.stderr) +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('ruff', 'F401', str(python_module))) + + assert ret == 0 + assert python_module.read_text() == """\ +import math +import sys + +print(math.pi, file=sys.stderr) +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> removing comments that silence errors +-> applying auto-fixes with ruff +Found 1 error (1 fixed, 0 remaining). +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +import math +import sys + +print(math.pi, file=sys.stderr) +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('ruff', 'F401', str(python_module))) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> removing comments that silence errors +no silenced errors found +""" diff --git a/tests/cli/silence_lint_error_test.py b/tests/cli/silence_lint_error_test.py new file mode 100644 index 0000000..20a635a --- /dev/null +++ b/tests/cli/silence_lint_error_test.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest import mock + +import pytest +from pytest_subprocess import FakeProcess + +from silence_lint_error.cli.silence_lint_error import main + + +class TestFixit: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + python_module = tmp_path / 't.py' + python_module.write_text( + """\ +x = None +isinstance(x, str) or isinstance(x, int) + +def f(x): + return isinstance(x, str) or isinstance(x, int) +""", + ) + + ret = main( + ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), + ) + + assert ret == 1 + assert python_module.read_text() == """\ +x = None +# lint-fixme: CollapseIsinstanceChecks +isinstance(x, str) or isinstance(x, int) + +def f(x): + # lint-fixme: CollapseIsinstanceChecks + return isinstance(x, str) or isinstance(x, int) +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with fixit +found errors in 1 files +-> adding comments to silence errors +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def foo(): + print('hello there') +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main( + ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), + ) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with fixit +no errors found +""" + + def test_main_multiple_different_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +x = None +isinstance(x, str) or isinstance(x, int) + +if True: + pass +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('fixit', 'fixit.rules', str(python_module))) + + assert ret == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with fixit +ERROR: errors found for multiple rules: ['CollapseIsinstanceChecks', 'NoStaticIfCondition'] +""" # noqa: B950 + + def test_not_installed(self, capsys: pytest.CaptureFixture[str]): + with FakeProcess() as process: + process.register( + (sys.executable, '-mfixit', process.any()), + returncode=1, stderr='/path/to/python3: No module named fixit\n', + ) + + ret = main(('fixit', 'fixit.rules', 'path/to/file.py')) + + assert ret == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with fixit +ERROR: /path/to/python3: No module named fixit +""" + + +class TestFixitInline: + def test_main_inline(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + python_module = tmp_path / 't.py' + python_module.write_text( + """\ +x = None +isinstance(x, str) or isinstance(x, int) + +def f(x): + return isinstance(x, str) or isinstance(x, int) +""", + ) + + ret = main( + ( + 'fixit-inline', + 'fixit.rules:CollapseIsinstanceChecks', + str(python_module), + ), + ) + + assert ret == 1 + assert python_module.read_text() == """\ +x = None +isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks + +def f(x): + return isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks +""" # noqa: B950 + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with fixit +found errors in 1 files +-> adding comments to silence errors +""" + + +class TestFlake8: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + python_module = tmp_path / 't.py' + python_module.write_text("""\ +import sys +import glob # noqa: F401 +from json import * # additional comment +from os import * # noqa: F403 +from pathlib import * # noqa: F403 # additional comment +""") + + ret = main(('flake8', 'F401', str(python_module))) + + assert ret == 1 + assert python_module.read_text() == """\ +import sys # noqa: F401 +import glob # noqa: F401 +from json import * # additional comment # noqa: F401 +from os import * # noqa: F401,F403 +from pathlib import * # noqa: F401,F403 # additional comment +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with flake8 +found errors in 1 files +-> adding comments to silence errors +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def foo(): + print('hello there') +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('flake8', 'F401', str(python_module))) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with flake8 +no errors found +""" + + def test_not_installed(self, capsys: pytest.CaptureFixture[str]): + with FakeProcess() as process: + process.register( + (sys.executable, '-mflake8', process.any()), + returncode=1, stderr='/path/to/python3: No module named flake8\n', + ) + + ret = main(('flake8', 'F401', 'path/to/file.py')) + + assert ret == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with flake8 +ERROR: /path/to/python3: No module named flake8 +""" + + +class TestRuff: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + python_module = tmp_path / 't.py' + python_module.write_text("""\ +import sys +import os # noqa: ABC1 +import json # additional comment +import glob # noqa: F401 +""") + + ret = main(('ruff', 'F401', str(python_module))) + + assert ret == 1 + assert python_module.read_text() == """\ +import sys # noqa: F401 +import os # noqa: F401,ABC1 +import json # additional comment # noqa: F401 +import glob # noqa: F401 +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with ruff +found errors in 1 files +-> adding comments to silence errors +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def foo(): + print('hello there') +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + ret = main(('ruff', 'F401', str(python_module))) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with ruff +no errors found +""" + + def test_not_installed(self, capsys: pytest.CaptureFixture[str]): + with FakeProcess() as process: + process.register( + (sys.executable, '-mruff', process.any()), + returncode=1, stderr='/path/to/python3: No module named ruff\n', + ) + + ret = main(('ruff', 'F401', 'path/to/file.py')) + + assert ret == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with ruff +ERROR: /path/to/python3: No module named ruff +""" + + +class TestSemgrep: + def test_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + python_module = tmp_path / 't.py' + python_module.write_text( + """\ +import time + +time.sleep(5) + +# a different error (open-never-closed) +fd = open('foo') +""", + ) + + with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): + ret = main( + ( + 'semgrep', + 'python.lang.best-practice.sleep.arbitrary-sleep', + str(python_module), + ), + ) + + assert ret == 1 + assert python_module.read_text() == """\ +import time + +# nosemgrep: python.lang.best-practice.sleep.arbitrary-sleep +time.sleep(5) + +# a different error (open-never-closed) +fd = open('foo') +""" + + captured = capsys.readouterr() + assert captured.out == f"""\ +{python_module} +""" + assert captured.err == """\ +-> finding errors with semgrep +found errors in 1 files +-> adding comments to silence errors +""" + + def test_main_no_violations( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str], + ): + src = """\ +def foo(): + print('hello there') +""" + + python_module = tmp_path / 't.py' + python_module.write_text(src) + + with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): + ret = main( + ( + 'semgrep', + 'python.lang.best-practice.sleep.arbitrary-sleep', + str(python_module), + ), + ) + + assert ret == 0 + assert python_module.read_text() == src + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with semgrep +no errors found +""" + + def test_not_installed(self, capsys: pytest.CaptureFixture[str]): + with FakeProcess() as process: + process.register( + ('semgrep', process.any()), + returncode=1, stderr='zsh: command not found: semgrep\n', + ) + + ret = main(('semgrep', 'semgrep.rule', 'path/to/file.py')) + + assert ret == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == """\ +-> finding errors with semgrep +ERROR: zsh: command not found: semgrep +""" diff --git a/tests/fix_silenced_error/fixit_test.py b/tests/fix_silenced_error/fixit_test.py deleted file mode 100644 index ad482e1..0000000 --- a/tests/fix_silenced_error/fixit_test.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from silence_lint_error.fix_silenced_error import main - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - src = """\ -x = None -# lint-fixme: CollapseIsinstanceChecks -isinstance(x, str) or isinstance(x, int) -isinstance(x, bool) or isinstance(x, float) # lint-fixme: CollapseIsinstanceChecks - -def f(x): - # lint-ignore: CollapseIsinstanceChecks - return isinstance(x, str) or isinstance(x, int) -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == """\ -x = None -isinstance(x, (str, int)) -isinstance(x, (bool, float)) - -def f(x): - # lint-ignore: CollapseIsinstanceChecks - return isinstance(x, str) or isinstance(x, int) -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> removing comments that silence errors --> applying auto-fixes with fixit -🛠️ 1 file checked, 1 file with errors, 2 auto-fixes available, 2 fixes applied 🛠️ -""" - - -def test_main_no_violations(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - src = """\ -def foo(): - print('hello there') -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> removing comments that silence errors -no silenced errors found -""" diff --git a/tests/fix_silenced_error/ruff_test.py b/tests/fix_silenced_error/ruff_test.py deleted file mode 100644 index 44e2a69..0000000 --- a/tests/fix_silenced_error/ruff_test.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from silence_lint_error.fix_silenced_error import main - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - src = """\ -import math -import os # noqa: F401 -import sys - -print(math.pi, file=sys.stderr) -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('ruff', 'F401', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == """\ -import math -import sys - -print(math.pi, file=sys.stderr) -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> removing comments that silence errors --> applying auto-fixes with ruff -Found 1 error (1 fixed, 0 remaining). -""" - - -def test_main_no_violations(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - src = """\ -import math -import sys - -print(math.pi, file=sys.stderr) -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('ruff', 'F401', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> removing comments that silence errors -no silenced errors found -""" diff --git a/tests/linters/__init__.py b/tests/linters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/linters/fixit_test.py b/tests/linters/fixit_test.py new file mode 100644 index 0000000..9b6fef2 --- /dev/null +++ b/tests/linters/fixit_test.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from silence_lint_error.linters.fixit import Fixit +from silence_lint_error.silencing import Violation + + +class TestFixit: + @pytest.mark.parametrize( + 'lines, expected_violations', ( + pytest.param( + ['t.py@1:2 MyRuleName: the error message'], + [('t.py', Violation('MyRuleName', 1))], + id='single-line', + ), + pytest.param( + ['t.py@1:2 MyRuleName: '], + [('t.py', Violation('MyRuleName', 1))], + id='no-message', + ), + pytest.param( + [ + 't.py@1:2 MyRuleName: the error message', + 'which continue over multiple lines', + 'just like this one does.', + ], + [ + ('t.py', Violation('MyRuleName', 1)), + None, + None, + ], + id='multi-line', + ), + pytest.param( + [ + 't.py@1:2 MyRuleName: ', + 'the error message on a new line', + 'which continue over multiple lines', + 'just like this one does.', + ], + [ + ('t.py', Violation('MyRuleName', 1)), + None, + None, + None, + ], + id='multi-line-leading-ws', + ), + pytest.param( + [ + 't.py@1:2 MyRuleName: the error message', + 'which continue over multiple lines', + 'just like this one does.', + '', + ], + [ + ('t.py', Violation('MyRuleName', 1)), + None, + None, + None, + ], + id='multi-line-trailing-ws', + ), + ), + ) + def test_parse_output_line( + self, + lines: list[str], + expected_violations: list[tuple[str, Violation] | None], + ): + violations = [ + Fixit()._parse_output_line(line) + for line in lines + ] + + assert violations == expected_violations + + def test_find_violations(self, tmp_path: Path): + python_module = tmp_path / 't.py' + python_module.write_text( + """\ +x = None +isinstance(x, str) or isinstance(x, int) +""", + ) + + violations = Fixit().find_violations( + 'fixit.rules:CollapseIsinstanceChecks', [str(python_module)], + ) + + assert violations == { + str(python_module): [ + Violation('CollapseIsinstanceChecks', 2), + ], + } diff --git a/tests/silence_lint_error/fixit_test.py b/tests/silence_lint_error/fixit_test.py deleted file mode 100644 index 08525f4..0000000 --- a/tests/silence_lint_error/fixit_test.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest -from pytest_subprocess import FakeProcess - -from silence_lint_error.silence_lint_error import Fixit -from silence_lint_error.silence_lint_error import main -from silence_lint_error.silence_lint_error import Violation - - -class TestFixit: - @pytest.mark.parametrize( - 'lines, expected_violations', ( - pytest.param( - ['t.py@1:2 MyRuleName: the error message'], - [('t.py', Violation('MyRuleName', 1))], - id='single-line', - ), - pytest.param( - ['t.py@1:2 MyRuleName: '], - [('t.py', Violation('MyRuleName', 1))], - id='no-message', - ), - pytest.param( - [ - 't.py@1:2 MyRuleName: the error message', - 'which continue over multiple lines', - 'just like this one does.', - ], - [ - ('t.py', Violation('MyRuleName', 1)), - None, - None, - ], - id='multi-line', - ), - pytest.param( - [ - 't.py@1:2 MyRuleName: ', - 'the error message on a new line', - 'which continue over multiple lines', - 'just like this one does.', - ], - [ - ('t.py', Violation('MyRuleName', 1)), - None, - None, - None, - ], - id='multi-line-leading-ws', - ), - pytest.param( - [ - 't.py@1:2 MyRuleName: the error message', - 'which continue over multiple lines', - 'just like this one does.', - '', - ], - [ - ('t.py', Violation('MyRuleName', 1)), - None, - None, - None, - ], - id='multi-line-trailing-ws', - ), - ), - ) - def test_parse_output_line( - self, - lines: list[str], - expected_violations: list[tuple[str, Violation] | None], - ): - violations = [ - Fixit()._parse_output_line(line) - for line in lines - ] - - assert violations == expected_violations - - def test_find_violations(self, tmp_path: Path): - python_module = tmp_path / 't.py' - python_module.write_text( - """\ -x = None -isinstance(x, str) or isinstance(x, int) -""", - ) - - violations = Fixit().find_violations( - 'fixit.rules:CollapseIsinstanceChecks', [str(python_module)], - ) - - assert violations == { - str(python_module): [ - Violation('CollapseIsinstanceChecks', 2), - ], - } - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - python_module = tmp_path / 't.py' - python_module.write_text( - """\ -x = None -isinstance(x, str) or isinstance(x, int) - -def f(x): - return isinstance(x, str) or isinstance(x, int) -""", - ) - - ret = main( - ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), - ) - - assert ret == 1 - assert python_module.read_text() == """\ -x = None -# lint-fixme: CollapseIsinstanceChecks -isinstance(x, str) or isinstance(x, int) - -def f(x): - # lint-fixme: CollapseIsinstanceChecks - return isinstance(x, str) or isinstance(x, int) -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> finding errors with fixit -found errors in 1 files --> adding comments to silence errors -""" - - -def test_main_inline(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - python_module = tmp_path / 't.py' - python_module.write_text( - """\ -x = None -isinstance(x, str) or isinstance(x, int) - -def f(x): - return isinstance(x, str) or isinstance(x, int) -""", - ) - - ret = main( - ('fixit-inline', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), - ) - - assert ret == 1 - assert python_module.read_text() == """\ -x = None -isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks - -def f(x): - return isinstance(x, str) or isinstance(x, int) # lint-fixme: CollapseIsinstanceChecks -""" # noqa: B950 - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> finding errors with fixit -found errors in 1 files --> adding comments to silence errors -""" - - -def test_main_no_violations( - tmp_path: Path, capsys: pytest.CaptureFixture[str], -): - src = """\ -def foo(): - print('hello there') -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main( - ('fixit', 'fixit.rules:CollapseIsinstanceChecks', str(python_module)), - ) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with fixit -no errors found -""" - - -def test_main_multiple_different_violations( - tmp_path: Path, capsys: pytest.CaptureFixture[str], -): - src = """\ -x = None -isinstance(x, str) or isinstance(x, int) - -if True: - pass -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('fixit', 'fixit.rules', str(python_module))) - - assert ret == 1 - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with fixit -ERROR: errors found for multiple rules: ['CollapseIsinstanceChecks', 'NoStaticIfCondition'] -""" # noqa: B950 - - -def test_not_installed(capsys: pytest.CaptureFixture[str]): - with FakeProcess() as process: - process.register( - (sys.executable, '-mfixit', process.any()), - returncode=1, stderr='/path/to/python3: No module named fixit\n', - ) - - ret = main(('fixit', 'fixit.rules', 'path/to/file.py')) - - assert ret == 1 - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with fixit -ERROR: /path/to/python3: No module named fixit -""" diff --git a/tests/silence_lint_error/flake8_test.py b/tests/silence_lint_error/flake8_test.py deleted file mode 100644 index e4e1478..0000000 --- a/tests/silence_lint_error/flake8_test.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest -from pytest_subprocess import FakeProcess - -from silence_lint_error.silence_lint_error import main - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - python_module = tmp_path / 't.py' - python_module.write_text("""\ -import sys -import glob # noqa: F401 -from json import * # additional comment -from os import * # noqa: F403 -from pathlib import * # noqa: F403 # additional comment -""") - - ret = main(('flake8', 'F401', str(python_module))) - - assert ret == 1 - assert python_module.read_text() == """\ -import sys # noqa: F401 -import glob # noqa: F401 -from json import * # additional comment # noqa: F401 -from os import * # noqa: F401,F403 -from pathlib import * # noqa: F401,F403 # additional comment -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> finding errors with flake8 -found errors in 1 files --> adding comments to silence errors -""" - - -def test_main_no_violations( - tmp_path: Path, capsys: pytest.CaptureFixture[str], -): - src = """\ -def foo(): - print('hello there') -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('flake8', 'F401', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with flake8 -no errors found -""" - - -def test_not_installed(capsys: pytest.CaptureFixture[str]): - with FakeProcess() as process: - process.register( - (sys.executable, '-mflake8', process.any()), - returncode=1, stderr='/path/to/python3: No module named flake8\n', - ) - - ret = main(('flake8', 'F401', 'path/to/file.py')) - - assert ret == 1 - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with flake8 -ERROR: /path/to/python3: No module named flake8 -""" diff --git a/tests/silence_lint_error/ruff_test.py b/tests/silence_lint_error/ruff_test.py deleted file mode 100644 index 01134be..0000000 --- a/tests/silence_lint_error/ruff_test.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest -from pytest_subprocess import FakeProcess - -from silence_lint_error.silence_lint_error import main - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - python_module = tmp_path / 't.py' - python_module.write_text("""\ -import sys -import os # noqa: ABC1 -import json # additional comment -import glob # noqa: F401 -""") - - ret = main(('ruff', 'F401', str(python_module))) - - assert ret == 1 - assert python_module.read_text() == """\ -import sys # noqa: F401 -import os # noqa: F401,ABC1 -import json # additional comment # noqa: F401 -import glob # noqa: F401 -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> finding errors with ruff -found errors in 1 files --> adding comments to silence errors -""" - - -def test_main_no_violations( - tmp_path: Path, capsys: pytest.CaptureFixture[str], -): - src = """\ -def foo(): - print('hello there') -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - ret = main(('ruff', 'F401', str(python_module))) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with ruff -no errors found -""" - - -def test_not_installed(capsys: pytest.CaptureFixture[str]): - with FakeProcess() as process: - process.register( - (sys.executable, '-mruff', process.any()), - returncode=1, stderr='/path/to/python3: No module named ruff\n', - ) - - ret = main(('ruff', 'F401', 'path/to/file.py')) - - assert ret == 1 - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with ruff -ERROR: /path/to/python3: No module named ruff -""" diff --git a/tests/silence_lint_error/semgrep_test.py b/tests/silence_lint_error/semgrep_test.py deleted file mode 100644 index 2ad7ceb..0000000 --- a/tests/silence_lint_error/semgrep_test.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from unittest import mock - -import pytest -from pytest_subprocess import FakeProcess - -from silence_lint_error.silence_lint_error import main - - -def test_main(tmp_path: Path, capsys: pytest.CaptureFixture[str]): - python_module = tmp_path / 't.py' - python_module.write_text( - """\ -import time - -time.sleep(5) - -# a different error (open-never-closed) -fd = open('foo') -""", - ) - - with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): - ret = main( - ( - 'semgrep', - 'python.lang.best-practice.sleep.arbitrary-sleep', - str(python_module), - ), - ) - - assert ret == 1 - assert python_module.read_text() == """\ -import time - -# nosemgrep: python.lang.best-practice.sleep.arbitrary-sleep -time.sleep(5) - -# a different error (open-never-closed) -fd = open('foo') -""" - - captured = capsys.readouterr() - assert captured.out == f"""\ -{python_module} -""" - assert captured.err == """\ --> finding errors with semgrep -found errors in 1 files --> adding comments to silence errors -""" - - -def test_main_no_violations( - tmp_path: Path, capsys: pytest.CaptureFixture[str], -): - src = """\ -def foo(): - print('hello there') -""" - - python_module = tmp_path / 't.py' - python_module.write_text(src) - - with mock.patch.dict(os.environ, {'SEMGREP_RULES': 'r/python'}): - ret = main( - ( - 'semgrep', - 'python.lang.best-practice.sleep.arbitrary-sleep', - str(python_module), - ), - ) - - assert ret == 0 - assert python_module.read_text() == src - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with semgrep -no errors found -""" - - -def test_not_installed(capsys: pytest.CaptureFixture[str]): - with FakeProcess() as process: - process.register( - ('semgrep', process.any()), - returncode=1, stderr='zsh: command not found: semgrep\n', - ) - - ret = main(('semgrep', 'semgrep.rule', 'path/to/file.py')) - - assert ret == 1 - - captured = capsys.readouterr() - assert captured.out == '' - assert captured.err == """\ --> finding errors with semgrep -ERROR: zsh: command not found: semgrep -"""