From e8a78add77e481e9878fd63be5ee7de27f6e60ab Mon Sep 17 00:00:00 2001 From: Kyle Edwards Date: Wed, 17 Jan 2024 17:47:42 -0500 Subject: [PATCH] Add conda -y check (#3) Reference: https://github.com/rapidsai/ops/issues/2993 Add a check to ensure that all calls to conda use the -y/--yes flag when appropriate to ensure non-interactivity. --- .github/workflows/run-tests.yaml | 25 ++ .pre-commit-hooks.yaml | 9 +- ci/build-test.sh | 15 + pyproject.toml | 20 +- src/rapids_pre_commit_hooks/lint.py | 234 ++++++++++++++ src/rapids_pre_commit_hooks/shell/__init__.py | 44 +++ .../shell/verify_conda_yes.py | 82 +++++ test/rapids_pre_commit_hooks/test_lint.py | 287 ++++++++++++++++++ test/rapids_pre_commit_hooks/test_shell.py | 88 ++++++ 9 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/run-tests.yaml create mode 100644 ci/build-test.sh create mode 100644 src/rapids_pre_commit_hooks/lint.py create mode 100644 src/rapids_pre_commit_hooks/shell/__init__.py create mode 100644 src/rapids_pre_commit_hooks/shell/verify_conda_yes.py create mode 100644 test/rapids_pre_commit_hooks/test_lint.py create mode 100644 test/rapids_pre_commit_hooks/test_shell.py diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..d48a350 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,25 @@ +name: Build and test + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build & Test + run: ./ci/build-test.sh diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index ce94db5..9cfc074 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2023-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,3 +11,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +- id: verify-conda-yes + name: pass -y/--yes to conda + description: make sure that all calls to conda pass -y/--yes + entry: verify-conda-yes + language: python + types: [shell] + args: [--fix] diff --git a/ci/build-test.sh b/ci/build-test.sh new file mode 100644 index 0000000..ce157d1 --- /dev/null +++ b/ci/build-test.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Builds and tests Python package + +set -ue + +pip install build pytest + +python -m build . + +for PKG in dist/*; do + echo "$PKG" + pip uninstall -y rapids-pre-commit-hooks + pip install "$PKG" + pytest +done diff --git a/pyproject.toml b/pyproject.toml index 0c17597..487bf24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -# Copyright (c) 2023, NVIDIA CORPORATION. +# Copyright (c) 2023-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -30,10 +30,26 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", ] -requires-python = ">=3.8" +requires-python = ">=3.9" +dependencies = [ + "bashlex", +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[project.scripts] +verify-conda-yes = "rapids_pre_commit_hooks.shell.verify_conda_yes:main" [tool.setuptools] packages = { "find" = { where = ["src"] } } [tool.isort] profile = "black" + +[tool.pytest.ini_options] +testpaths = [ + "test", +] diff --git a/src/rapids_pre_commit_hooks/lint.py b/src/rapids_pre_commit_hooks/lint.py new file mode 100644 index 0000000..9c4b976 --- /dev/null +++ b/src/rapids_pre_commit_hooks/lint.py @@ -0,0 +1,234 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import bisect +import contextlib +import functools +import itertools + + +class OverlappingReplacementsError(RuntimeError): + pass + + +class Replacement: + def __init__(self, pos, newtext): + self.pos = pos + self.newtext = newtext + + def __eq__(self, other): + if not isinstance(other, Replacement): + return False + return self.pos == other.pos and self.newtext == other.newtext + + def __repr__(self): + return f"Replacement(pos={self.pos}, newtext={repr(self.newtext)})" + + +class LintWarning: + def __init__(self, pos, msg): + self.pos = pos + self.msg = msg + self.replacements = [] + + def add_replacement(self, pos, newtext): + self.replacements.append(Replacement(pos, newtext)) + + def __eq__(self, other): + if not isinstance(other, LintWarning): + return False + return ( + self.pos == other.pos + and self.msg == other.msg + and self.replacements == other.replacements + ) + + def __repr__(self): + return ( + "LintWarning(" + + f"pos={self.pos}, " + + f"msg={self.msg}, " + + f"replacements={self.replacements})" + ) + + +class Linter: + def __init__(self, filename, content): + self.filename = filename + self.content = content + self.warnings = [] + self._calculate_lines() + + def add_warning(self, pos, msg): + w = LintWarning(pos, msg) + self.warnings.append(w) + return w + + def fix(self): + sorted_replacements = sorted( + ( + replacement + for warning in self.warnings + for replacement in warning.replacements + ), + key=lambda replacement: replacement.pos, + ) + + for r1, r2 in itertools.pairwise(sorted_replacements): + if r1.pos[1] > r2.pos[0]: + raise OverlappingReplacementsError(f"{r1} overlaps with {r2}") + + cursor = 0 + replaced_content = "" + for replacement in sorted_replacements: + replaced_content += self.content[cursor : replacement.pos[0]] + replaced_content += replacement.newtext + cursor = replacement.pos[1] + + replaced_content += self.content[cursor:] + return replaced_content + + def print_warnings(self, fix_applied=False): + sorted_warnings = sorted(self.warnings, key=lambda warning: warning.pos) + + for warning in sorted_warnings: + line_index = self.line_for_pos(warning.pos[0]) + print(f"In file {self.filename}:{line_index + 1}:") + self.print_highlighted_code(warning.pos) + print(f"warning: {warning.msg}") + print() + + for replacement in warning.replacements: + line_index = self.line_for_pos(replacement.pos[0]) + print(f"In file {self.filename}:{line_index + 1}:") + self.print_highlighted_code(replacement.pos, replacement.newtext) + if fix_applied: + print("note: suggested fix applied") + else: + print("note: suggested fix") + print() + + def print_highlighted_code(self, pos, replacement=""): + line_index = self.line_for_pos(pos[0]) + line_pos = self.lines[line_index] + left = pos[0] - line_pos[0] + + if self.line_for_pos(pos[1]) == line_index: + right = pos[1] - line_pos[0] + else: + right = line_pos[1] - line_pos[0] + length = right - left + + print(self.content[line_pos[0] : line_pos[1]]) + print(" " * left, end="") + if length == 0: + print(f"^{replacement}") + else: + print(f"{'~' * length}{replacement}") + + def line_for_pos(self, index): + @functools.total_ordering + class LineComparator: + def __init__(self, pos): + self.pos = pos + + def __lt__(self, other): + return self.pos[1] < other + + def __gt__(self, other): + return self.pos[0] > other + + def __eq__(self, other): + return self.pos[0] <= other <= self.pos[1] + + line_index = bisect.bisect_left(self.lines, index, key=LineComparator) + try: + line_pos = self.lines[line_index] + except IndexError: + return None + if line_pos[0] <= index <= line_pos[1]: + return line_index + return None + + def _calculate_lines(self): + self.lines = [] + + line_begin = 0 + line_end = 0 + state = "c" + + for c in self.content: + if state == "c": + if c == "\r": + self.lines.append((line_begin, line_end)) + line_end = line_begin = line_end + 1 + state = "r" + elif c == "\n": + self.lines.append((line_begin, line_end)) + line_end = line_begin = line_end + 1 + else: + line_end += 1 + elif state == "r": + if c == "\r": + self.lines.append((line_begin, line_end)) + line_end = line_begin = line_end + 1 + elif c == "\n": + line_end = line_begin = line_end + 1 + state = "c" + else: + line_end += 1 + state = "c" + + self.lines.append((line_begin, line_end)) + + +class LintMain(contextlib.AbstractContextManager): + def __init__(self): + self.argparser = argparse.ArgumentParser() + self.argparser.add_argument("--fix", action="store_true") + self.argparser.add_argument("file", nargs="+") + self.checks = [] + + def add_check(self, check): + self.checks.append(check) + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type: + return + + warnings = False + + args = self.argparser.parse_args() + + for file in args.file: + with open(file) as f: + content = f.read() + + linter = Linter(file, content) + for check in self.checks: + check(linter, args) + + linter.print_warnings(args.fix) + if args.fix: + fix = linter.fix() + if fix != content: + with open(file, "w") as f: + f.write(fix) + + if len(linter.warnings) > 0: + warnings = True + + if warnings: + exit(1) diff --git a/src/rapids_pre_commit_hooks/shell/__init__.py b/src/rapids_pre_commit_hooks/shell/__init__.py new file mode 100644 index 0000000..0056bae --- /dev/null +++ b/src/rapids_pre_commit_hooks/shell/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bashlex + +from ..lint import LintMain + + +class LintVisitor(bashlex.ast.nodevisitor): + def __init__(self, linter, args): + self.linter = linter + self.args = args + + def add_warning(self, pos, msg): + return self.linter.add_warning(pos, msg) + + +class ShellMain(LintMain): + def __init__(self): + super().__init__() + self.visitors = [] + self.add_check(self.check_shell) + + def add_visitor_class(self, cls): + self.visitors.append(cls) + + def check_shell(self, linter, args): + parts = bashlex.parse(linter.content) + + for cls in self.visitors: + visitor = cls(linter, args) + for part in parts: + visitor.visit(part) diff --git a/src/rapids_pre_commit_hooks/shell/verify_conda_yes.py b/src/rapids_pre_commit_hooks/shell/verify_conda_yes.py new file mode 100644 index 0000000..e70b9b5 --- /dev/null +++ b/src/rapids_pre_commit_hooks/shell/verify_conda_yes.py @@ -0,0 +1,82 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import LintVisitor, ShellMain + +INTERACTIVE_CONDA_COMMANDS = { + "clean": { + "args": ["-y", "--yes"], + }, + "create": { + "args": ["-y", "--yes"], + }, + "install": { + "args": ["-y", "--yes"], + }, + "remove": { + "args": ["-y", "--yes"], + }, + "uninstall": { + "args": ["-y", "--yes"], + }, + "update": { + "args": ["-y", "--yes"], + }, + "upgrade": { + "args": ["-y", "--yes"], + }, +} + + +class VerifyCondaYesVisitor(LintVisitor): + def visitcommand(self, n, parts): + part_words = [part.word for part in parts] + if part_words[0] != "conda": + return + + try: + command_index = next( + i + for i, word in enumerate(part_words) + if word not in {"conda", "-h", "--help", "--no-plugins", "-V"} + ) + except StopIteration: + return + if any(arg in {"-h", "--help", "-V"} for arg in part_words[1:command_index]): + return + + command_name = part_words[command_index] + command_args = part_words[command_index:] + try: + command = INTERACTIVE_CONDA_COMMANDS[command_name] + except KeyError: + return + + if not any(arg in command["args"] for arg in command_args): + warning_pos = (parts[0].pos[0], parts[command_index].pos[1]) + insert_pos = (warning_pos[1], warning_pos[1]) + + warning = self.add_warning( + warning_pos, f"add {command['args'][0]} argument" + ) + warning.add_replacement(insert_pos, f" {command['args'][0]}") + + +def main(): + with ShellMain() as m: + m.add_visitor_class(VerifyCondaYesVisitor) + + +if __name__ == "__main__": + main() diff --git a/test/rapids_pre_commit_hooks/test_lint.py b/test/rapids_pre_commit_hooks/test_lint.py new file mode 100644 index 0000000..0be3782 --- /dev/null +++ b/test/rapids_pre_commit_hooks/test_lint.py @@ -0,0 +1,287 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import contextlib +import sys +import tempfile + +import pytest + +from rapids_pre_commit_hooks.lint import Linter, LintMain, OverlappingReplacementsError + + +class MockArgv(contextlib.AbstractContextManager): + def __init__(self, *argv): + self.argv = argv + + def __enter__(self): + self.old_argv = sys.argv + sys.argv = list(self.argv) + + def __exit__(self, exc_type, exc_value, traceback): + sys.argv = self.old_argv + + +class TestLinter: + def test_lines(self): + linter = Linter( + "test.txt", + "line 1\nline 2\rline 3\r\nline 4\r\n\nline 6\r\n\r\nline 8\n\r\n" + + "line 10\r\r\nline 12\r\n\rline 14\n\nline 16\r\rline 18\n\rline 20", + ) + assert linter.lines == [ + (0, 6), + (7, 13), + (14, 20), + (22, 28), + (30, 30), + (31, 37), + (39, 39), + (41, 47), + (48, 48), + (50, 57), + (58, 58), + (60, 67), + (69, 69), + (70, 77), + (78, 78), + (79, 86), + (87, 87), + (88, 95), + (96, 96), + (97, 104), + ] + + linter = Linter("test.txt", "line 1\n") + assert linter.lines == [ + (0, 6), + (7, 7), + ] + + linter = Linter("test.txt", "line 1\r\n") + assert linter.lines == [ + (0, 6), + (8, 8), + ] + + linter = Linter("test.txt", "") + assert linter.lines == [ + (0, 0), + ] + + def test_line_for_pos(self): + linter = Linter( + "test.txt", + "line 1\nline 2\rline 3\r\nline 4\r\n\nline 6\r\n\r\nline 8\n\r\n" + + "line 10\r\r\nline 12\r\n\rline 14\n\nline 16\r\rline 18\n\rline 20", + ) + assert linter.line_for_pos(0) == 0 + assert linter.line_for_pos(3) == 0 + assert linter.line_for_pos(6) == 0 + assert linter.line_for_pos(10) == 1 + assert linter.line_for_pos(21) is None + assert linter.line_for_pos(34) == 5 + assert linter.line_for_pos(97) == 19 + assert linter.line_for_pos(104) == 19 + assert linter.line_for_pos(200) is None + + linter = Linter("test.txt", "line 1") + assert linter.line_for_pos(0) == 0 + assert linter.line_for_pos(3) == 0 + assert linter.line_for_pos(6) == 0 + + def test_fix(self): + linter = Linter("test.txt", "Hello world!") + assert linter.fix() == "Hello world!" + + linter.add_warning((0, 0), "no fix") + assert linter.fix() == "Hello world!" + + linter.add_warning((5, 5), "use punctuation").add_replacement((5, 5), ",") + linter.add_warning((0, 5), "say good bye instead").add_replacement( + (0, 5), "Good bye" + ) + linter.add_warning((11, 12), "don't shout").add_replacement((11, 12), "") + linter.add_warning((6, 11), "no-op replacement").add_replacement((11, 11), "") + assert linter.fix() == "Good bye, world" + + linter.add_warning((11, 12), "don't shout").add_replacement((11, 12), ".") + with pytest.raises( + OverlappingReplacementsError, + match=r"^Replacement\(pos=\(11, 12\), newtext=''\) overlaps with " + + r"Replacement\(pos=\(11, 12\), newtext='\.'\)$", + ): + linter.fix() + + +class TestLintMain: + @pytest.fixture + def tmpfile(self): + f = tempfile.NamedTemporaryFile("w+") + f.write("Hello world!") + f.flush() + f.seek(0) + return f + + @pytest.fixture + def tmpfile2(self): + f = tempfile.NamedTemporaryFile("w+") + f.write("Hello!") + f.flush() + f.seek(0) + return f + + def the_check(self, linter, args): + assert args.check_test + linter.add_warning((0, 5), "say good bye instead").add_replacement( + (0, 5), "Good bye" + ) + if linter.content[5] != "!": + linter.add_warning((5, 5), "use punctuation").add_replacement( + (5, 5), "," + ) + + def test_no_warnings_no_fix(self, tmpfile, capsys): + with tmpfile: + with MockArgv("check-test", "--check-test", tmpfile.name): + with LintMain() as m: + m.argparser.add_argument("--check-test", action="store_true") + assert tmpfile.read() == "Hello world!" + captured = capsys.readouterr() + assert captured.out == "" + + def test_no_warnings_fix(self, tmpfile, capsys): + with tmpfile: + with MockArgv("check-test", "--check-test", "--fix", tmpfile.name): + with LintMain() as m: + m.argparser.add_argument("--check-test", action="store_true") + assert tmpfile.read() == "Hello world!" + captured = capsys.readouterr() + assert captured.out == "" + + def test_warnings_no_fix(self, tmpfile, capsys): + with tmpfile: + with MockArgv("check-test", "--check-test", tmpfile.name), pytest.raises( + SystemExit, match=r"^1$" + ): + with LintMain() as m: + m.argparser.add_argument("--check-test", action="store_true") + m.add_check(self.the_check) + assert tmpfile.read() == "Hello world!" + captured = capsys.readouterr() + assert ( + captured.out + == f"""In file {tmpfile.name}:1: +Hello world! +~~~~~ +warning: say good bye instead + +In file {tmpfile.name}:1: +Hello world! +~~~~~Good bye +note: suggested fix + +In file {tmpfile.name}:1: +Hello world! + ^ +warning: use punctuation + +In file {tmpfile.name}:1: +Hello world! + ^, +note: suggested fix + +""" + ) + + def test_warnings_fix(self, tmpfile, capsys): + with tmpfile: + with MockArgv( + "check-test", "--check-test", "--fix", tmpfile.name + ), pytest.raises(SystemExit, match=r"^1$"): + with LintMain() as m: + m.argparser.add_argument("--check-test", action="store_true") + m.add_check(self.the_check) + assert tmpfile.read() == "Good bye, world!" + captured = capsys.readouterr() + assert ( + captured.out + == f"""In file {tmpfile.name}:1: +Hello world! +~~~~~ +warning: say good bye instead + +In file {tmpfile.name}:1: +Hello world! +~~~~~Good bye +note: suggested fix applied + +In file {tmpfile.name}:1: +Hello world! + ^ +warning: use punctuation + +In file {tmpfile.name}:1: +Hello world! + ^, +note: suggested fix applied + +""" + ) + + def test_multiple_files(self, tmpfile, tmpfile2, capsys): + with tmpfile, tmpfile2: + with MockArgv( + "check-test", "--check-test", "--fix", tmpfile.name, tmpfile2.name + ), pytest.raises(SystemExit, match=r"^1$"): + with LintMain() as m: + m.argparser.add_argument("--check-test", action="store_true") + m.add_check(self.the_check) + assert tmpfile.read() == "Good bye, world!" + assert tmpfile2.read() == "Good bye!" + captured = capsys.readouterr() + assert ( + captured.out + == f"""In file {tmpfile.name}:1: +Hello world! +~~~~~ +warning: say good bye instead + +In file {tmpfile.name}:1: +Hello world! +~~~~~Good bye +note: suggested fix applied + +In file {tmpfile.name}:1: +Hello world! + ^ +warning: use punctuation + +In file {tmpfile.name}:1: +Hello world! + ^, +note: suggested fix applied + +In file {tmpfile2.name}:1: +Hello! +~~~~~ +warning: say good bye instead + +In file {tmpfile2.name}:1: +Hello! +~~~~~Good bye +note: suggested fix applied + +""" + ) diff --git a/test/rapids_pre_commit_hooks/test_shell.py b/test/rapids_pre_commit_hooks/test_shell.py new file mode 100644 index 0000000..f5ddbf2 --- /dev/null +++ b/test/rapids_pre_commit_hooks/test_shell.py @@ -0,0 +1,88 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bashlex + +from rapids_pre_commit_hooks.lint import Linter +from rapids_pre_commit_hooks.shell.verify_conda_yes import VerifyCondaYesVisitor + + +def run_shell_linter(content, cls): + linter = Linter("test.sh", content) + visitor = cls(linter, None) + parts = bashlex.parse(content) + for part in parts: + visitor.visit(part) + return linter + + +def test_verify_conda_yes(): + CONTENT = r""" +conda install -y pkg1 +conda install --yes pkg2 pkg3 +conda install pkg4 +conda -h install +conda --help install +if true; then + conda --no-plugins install pkg5 +fi +# conda install pkg6 +conda -V +conda +conda clean -y +conda clean +conda create -y +conda create +conda remove -y +conda remove +conda uninstall -y +conda uninstall +conda update -y +conda update +conda upgrade -y +conda upgrade +conda search +conda install $pkg1 "$pkg2" +""" + expected_linter = Linter("test.sh", CONTENT) + expected_linter.add_warning((53, 66), "add -y argument").add_replacement( + (66, 66), " -y" + ) + expected_linter.add_warning((126, 152), "add -y argument").add_replacement( + (152, 152), " -y" + ) + expected_linter.add_warning((212, 223), "add -y argument").add_replacement( + (223, 223), " -y" + ) + expected_linter.add_warning((240, 252), "add -y argument").add_replacement( + (252, 252), " -y" + ) + expected_linter.add_warning((269, 281), "add -y argument").add_replacement( + (281, 281), " -y" + ) + expected_linter.add_warning((301, 316), "add -y argument").add_replacement( + (316, 316), " -y" + ) + expected_linter.add_warning((333, 345), "add -y argument").add_replacement( + (345, 345), " -y" + ) + expected_linter.add_warning((363, 376), "add -y argument").add_replacement( + (376, 376), " -y" + ) + expected_linter.add_warning((390, 403), "add -y argument").add_replacement( + (403, 403), " -y" + ) + + linter = run_shell_linter(CONTENT, VerifyCondaYesVisitor) + assert linter.warnings == expected_linter.warnings