Repository: asottile/all-repos

Issue:
autofix_lib: --interactive
Add an interactive mode for autofixers which utilize `autofix_lib`

Discussion:

Patch:
diff --git a/all_repos/autofix_lib.py b/all_repos/autofix_lib.py
index 8407bee..8b2372e 100644
--- a/all_repos/autofix_lib.py
+++ b/all_repos/autofix_lib.py
@@ -27,7 +27,8 @@ class Commit(collections.namedtuple(
 
 
 class AutofixSettings(collections.namedtuple(
-        'AutofixSettings', ('jobs', 'color', 'limit', 'dry_run'),
+        'AutofixSettings',
+        ('jobs', 'color', 'limit', 'dry_run', 'interactive'),
 )):
     __slots__ = ()
 
@@ -35,7 +36,7 @@ class AutofixSettings(collections.namedtuple(
     def from_cli(cls, args):
         return cls(
             jobs=args.jobs, color=args.color, limit=args.limit,
-            dry_run=args.dry_run,
+            dry_run=args.dry_run, interactive=args.interactive,
         )
 
 
@@ -112,6 +113,43 @@ def repo_context(repo, *, use_color):
         traceback.print_exc()
 
 
+def _interactive_check(*, use_color):
+    def _quit():
+        print('Goodbye!')
+        raise SystemExit()
+
+    while True:
+        try:
+            s = input(color.fmt(
+                '***Looks good [y,n,s,q,?]? ',
+                color.BLUE_B, use_color=use_color,
+            ))
+        except (EOFError, KeyboardInterrupt):
+            _quit()
+
+        s = s.strip().lower()
+        if s in {'y', 'yes'}:
+            return True
+        elif s in {'n', 'no'}:
+            return False
+        elif s in {'s', 'shell'}:
+            print('Opening an interactive shell, type `exit` to continue.')
+            print('Any modifications will be committed.')
+            subprocess.call(os.environ.get('SHELL', 'bash'))
+        elif s in {'q', 'quit'}:
+            _quit()
+        else:
+            if s not in {'?', 'help'}:
+                print(color.fmt(
+                    f'Unexpected input: {s}', color.RED, use_color=use_color,
+                ))
+            print('y (yes): yes it looks good, commit and continue.')
+            print('n (no): no, do not commit this repository.')
+            print('s (shell): open an interactive shell in the repo.')
+            print('q (quit, ^C): early exit from the autofixer.')
+            print('? (help): show this help message.')
+
+
 def _fix_inner(repo, apply_fix, check_fix, config, commit, autofix_settings):
     with repo_context(repo, use_color=autofix_settings.color):
         branch_name = f'all-repos_autofix_{commit.branch_name}'
@@ -125,6 +163,12 @@ def _fix_inner(repo, apply_fix, check_fix, config, commit, autofix_settings):
 
         check_fix()
 
+        if (
+                autofix_settings.interactive and
+                not _interactive_check(use_color=autofix_settings.color)
+        ):
+            return
+
         commit_message = (
             f'{commit.msg}\n\n'
             f'Committed via https://github.com/asottile/all-repos'
@@ -153,6 +197,7 @@ def fix(
         commit: Commit,
         autofix_settings: AutofixSettings,
 ):
+    assert not autofix_settings.interactive or autofix_settings.jobs == 1
     repos = tuple(repos)[:autofix_settings.limit]
     func = functools.partial(
         _fix_inner,
diff --git a/all_repos/cli.py b/all_repos/cli.py
index 40f5177..2357878 100644
--- a/all_repos/cli.py
+++ b/all_repos/cli.py
@@ -40,8 +40,16 @@ def add_color_arg(parser):
 
 def add_fixer_args(parser):
     add_config_arg(parser)
-    add_jobs_arg(parser, default=1)
     add_color_arg(parser)
+
+    mutex = parser.add_mutually_exclusive_group()
+    mutex.add_argument('--dry-run', action='store_true')
+    mutex.add_argument(
+        '-i', '--interactive', help='Interactively approve / deny fixes.',
+    )
+    add_jobs_arg(mutex, default=1)
+
+    parser.add_argument('--limit', type=int, default=None)
     parser.add_argument(
         '--author',
         help=(
@@ -50,6 +58,4 @@ def add_fixer_args(parser):
             "An example: `--author='Herp Derp <herp.derp@umich.edu>'`"
         ),
     )
-    parser.add_argument('--dry-run', action='store_true')
-    parser.add_argument('--limit', type=int, default=None)
     parser.add_argument('--repos', nargs='*')
diff --git a/all_repos/color.py b/all_repos/color.py
index f2def75..0f9a61d 100644
--- a/all_repos/color.py
+++ b/all_repos/color.py
@@ -1,4 +1,5 @@
 BLUE_B = '\033[1;34m'
+RED = '\033[31m'
 RED_H = '\033[41m'
 TURQUOISE = '\033[36m'
 TURQUOISE_H = '\033[46;30m'


Test patch:
diff --git a/tests/autofix_lib_test.py b/tests/autofix_lib_test.py
index 9a1a57c..b3ab17d 100644
--- a/tests/autofix_lib_test.py
+++ b/tests/autofix_lib_test.py
@@ -1,5 +1,7 @@
+import builtins
 import os
 import subprocess
+from unittest import mock
 
 import pytest
 from pre_commit.constants import VERSION as PRE_COMMIT_VERSION
@@ -90,6 +92,120 @@ def test_repo_context_errors(file_config_files, capsys):
     assert 'assert False' in err
 
 
+def _get_input_side_effect(*inputs):
+    it = iter(inputs)
+
+    def side_effect(s):
+        print(s, end='')
+        ret = next(it)
+        if ret in (EOFError, KeyboardInterrupt):
+            print({EOFError: '^D', KeyboardInterrupt: '^C'}[ret])
+            raise ret
+        else:
+            print(f'<<{ret}')
+            return ret
+    return side_effect
+
+
+@pytest.yield_fixture
+def mock_input():
+    with mock.patch.object(builtins, 'input') as mck:
+        yield mck
+
+
+def test_interactive_control_c(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect(KeyboardInterrupt)
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? ^C\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_eof(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect(EOFError)
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? ^D\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_quit(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('q')
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<q\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_yes(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('y')
+    assert autofix_lib._interactive_check(use_color=False) is True
+    out, _ = capfd.readouterr()
+    assert out == '***Looks good [y,n,s,q,?]? <<y\n'
+
+
+def test_interactive_no(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == '***Looks good [y,n,s,q,?]? <<n\n'
+
+
+def test_interactive_shell(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('s', 'n')
+    with mock.patch.dict(os.environ, {'SHELL': 'echo'}):
+        assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<s\n'
+        'Opening an interactive shell, type `exit` to continue.\n'
+        'Any modifications will be committed.\n'
+        # A newline from echo
+        '\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
+def test_interactive_help(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('?', 'n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<?\n'
+        'y (yes): yes it looks good, commit and continue.\n'
+        'n (no): no, do not commit this repository.\n'
+        's (shell): open an interactive shell in the repo.\n'
+        'q (quit, ^C): early exit from the autofixer.\n'
+        '? (help): show this help message.\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
+def test_interactive_garbage(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('garbage', 'n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<garbage\n'
+        'Unexpected input: garbage\n'
+        'y (yes): yes it looks good, commit and continue.\n'
+        'n (no): no, do not commit this repository.\n'
+        's (shell): open an interactive shell in the repo.\n'
+        'q (quit, ^C): early exit from the autofixer.\n'
+        '? (help): show this help message.\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
 def lower_case_f():
     f_contents = open('f').read()
     with open('f', 'w') as f:
@@ -110,7 +226,7 @@ def test_fix_dry_run_no_change(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=True,
+            jobs=1, color=False, limit=None, dry_run=True, interactive=False,
         ),
     )
 
@@ -136,7 +252,7 @@ def test_fix_with_limit(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=1, dry_run=True,
+            jobs=1, color=False, limit=1, dry_run=True, interactive=False,
         ),
     )
 
@@ -148,6 +264,25 @@ def test_fix_with_limit(file_config_files, capfd):
     assert '-OHELLO\n+ohello\n' not in out
 
 
+def test_fix_interactive(file_config_files, capfd, mock_input):
+    mock_input.side_effect = _get_input_side_effect('y', 'n')
+    autofix_lib.fix(
+        (
+            str(file_config_files.output_dir.join('repo1')),
+            str(file_config_files.output_dir.join('repo2')),
+        ),
+        apply_fix=lower_case_f,
+        config=load_config(file_config_files.cfg),
+        commit=autofix_lib.Commit('message!', 'test-branch', None),
+        autofix_settings=autofix_lib.AutofixSettings(
+            jobs=1, color=False, limit=None, dry_run=False, interactive=True,
+        ),
+    )
+
+    assert file_config_files.dir1.join('f').read() == 'ohai\n'
+    assert file_config_files.dir2.join('f').read() == 'OHELLO\n'
+
+
 def test_autofix_makes_commits(file_config_files, capfd):
     autofix_lib.fix(
         (
@@ -158,7 +293,7 @@ def test_autofix_makes_commits(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', 'A B <a@a.a>'),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
 
@@ -201,7 +336,7 @@ def test_fix_failing_check_no_changes(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
 
@@ -226,7 +361,7 @@ def test_noop_does_not_commit(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
     rev_after1 = testing.git.revparse(file_config_files.dir1)