Repository: boxed/mutmut

Issue:
Using click's subcommands
As discussed on #55, we should add support for `click`'s subcommand feature: http://click.palletsprojects.com/en/7.x/commands/. This provides greater control on arguments for each individual command, and should be a backwards compatible change. This would also improve extensibility of the CLI.

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 03768a0..e2c35e3 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -8,6 +8,10 @@ Changelog
 
 * Fixed error where ``mutmut_config.init()`` was not called when running without explicitly having set ``PYTHONPATH``
 
+* Use ``Click``'s subcommand feature to refactor the command line interface. For the end user, this can now run ``mutmut [COMMAND] -h``
+  to check which parameters are relevant to this specific subcommand. The change is backwards compatible, and all existing commands
+  work the same as before, with the exception of ``mutmut --version``, which now has to be ``mutmut version``.
+
 2.2.0
 ~~~~~
 
diff --git a/mutmut/__init__.py b/mutmut/__init__.py
index 0d98509..76703c7 100644
--- a/mutmut/__init__.py
+++ b/mutmut/__init__.py
@@ -808,7 +808,7 @@ def run_mutation(context: Context, callback) -> str:
 class Config(object):
     def __init__(self, swallow_output, test_command, covered_lines_by_filename,
                  baseline_time_elapsed, test_time_multiplier, test_time_base,
-                 backup, dict_synonyms, total, using_testmon, cache_only,
+                 dict_synonyms, total, using_testmon, cache_only,
                  tests_dirs, hash_of_tests, pre_mutation, post_mutation,
                  coverage_data, paths_to_mutate, mutation_types_to_apply, no_progress):
         self.swallow_output = swallow_output
@@ -817,7 +817,6 @@ class Config(object):
         self.baseline_time_elapsed = baseline_time_elapsed
         self.test_time_multipler = test_time_multiplier
         self.test_time_base = test_time_base
-        self.backup = backup
         self.dict_synonyms = dict_synonyms
         self.total = total
         self.using_testmon = using_testmon
diff --git a/mutmut/__main__.py b/mutmut/__main__.py
index 4b6fb74..c2a761d 100644
--- a/mutmut/__main__.py
+++ b/mutmut/__main__.py
@@ -81,15 +81,27 @@ null_out = open(os.devnull, 'w')
 
 DEFAULT_RUNNER = 'python -m pytest -x --assert=plain'
 
-@click.command(context_settings=dict(help_option_names=['-h', '--help']))
-@click.argument('command', nargs=1, required=False)
+@click.group(context_settings=dict(help_option_names=['-h', '--help']))
+def climain():
+    """
+    Mutation testing system for Python.
+    """
+    pass
+
+
+@climain.command()
+def version():
+    """Show the version and exit."""
+    print("mutmut version {}".format(__version__))
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
 @click.argument('argument', nargs=1, required=False)
-@click.argument('argument2', nargs=1, required=False)
 @click.option('--paths-to-mutate', type=click.STRING)
 @click.option('--disable-mutation-types', type=click.STRING, help='Skip the given types of mutations.')
 @click.option('--enable-mutation-types', type=click.STRING, help='Only perform given types of mutations.')
 @click.option('--paths-to-exclude', type=click.STRING)
-@click.option('--backup/--no-backup', default=False)
 @click.option('--runner')
 @click.option('--use-coverage', is_flag=True, default=False)
 @click.option('--use-patch-file', help='Only mutate lines added/changed in the given patch file')
@@ -99,9 +111,6 @@ DEFAULT_RUNNER = 'python -m pytest -x --assert=plain'
 @click.option('-s', '--swallow-output', help='turn off output capture', is_flag=True)
 @click.option('--dict-synonyms')
 @click.option('--cache-only', is_flag=True, default=False)
-@click.option('--version', is_flag=True, default=False)
-@click.option('--suspicious-policy', type=click.Choice(['ignore', 'skipped', 'error', 'failure']), default='ignore')
-@click.option('--untested-policy', type=click.Choice(['ignore', 'skipped', 'error', 'failure']), default='ignore')
 @click.option('--pre-mutation')
 @click.option('--post-mutation')
 @click.option('--simple-output', is_flag=True, default=False, help="Swap emojis in mutmut output to plain text alternatives.")
@@ -115,54 +124,126 @@ DEFAULT_RUNNER = 'python -m pytest -x --assert=plain'
     post_mutation=None,
     use_patch_file=None,
 )
-def climain(command, argument, argument2, paths_to_mutate, disable_mutation_types, enable_mutation_types,
-            backup, runner, tests_dir, test_time_multiplier, test_time_base, swallow_output, use_coverage, 
-            dict_synonyms, cache_only, version, suspicious_policy, untested_policy, pre_mutation, 
-            post_mutation, use_patch_file, paths_to_exclude, simple_output, no_progress):
+def run(argument, paths_to_mutate, disable_mutation_types, enable_mutation_types, runner,
+        tests_dir, test_time_multiplier, test_time_base, swallow_output, use_coverage, 
+        dict_synonyms, cache_only, pre_mutation, post_mutation, use_patch_file, paths_to_exclude,
+        simple_output, no_progress):
     """
-commands:\n
-    run [mutation id]\n
-        Runs mutmut. You probably want to start with just trying this. If you supply a mutation ID mutmut will check just this mutant.\n
-    results\n
-        Print the results.\n
-    result-ids survived (or any other of: killed,timeout,suspicious,skipped,untested)\n
-        Print the IDs of the specified mutant classes (separated by spaces).\n
-    apply [mutation id]\n
-        Apply a mutation on disk.\n
-    show [mutation id]\n
-        Show a mutation diff.\n
-    show [path to file]\n
-        Show all mutation diffs for this file.\n
-    junitxml\n
-        Show a mutation diff with junitxml format.
+    Runs mutmut. You probably want to start with just trying this. If you supply a mutation ID mutmut will check just this mutant.
     """
     if test_time_base is None:  # click sets the default=0.0 to None
         test_time_base = 0.0
     if test_time_multiplier is None:  # click sets the default=0.0 to None
         test_time_multiplier = 0.0
-    sys.exit(main(command, argument, argument2, paths_to_mutate, disable_mutation_types, 
-                  enable_mutation_types, backup, runner,
-                  tests_dir, test_time_multiplier, test_time_base,
-                  swallow_output, use_coverage, dict_synonyms, cache_only,
-                  version, suspicious_policy, untested_policy, pre_mutation,
-                  post_mutation, use_patch_file, paths_to_exclude, simple_output,
-                  no_progress))
-
-
-def main(command, argument, argument2, paths_to_mutate, disable_mutation_types, 
-         enable_mutation_types, backup, runner, tests_dir, test_time_multiplier, test_time_base,
-         swallow_output, use_coverage, dict_synonyms, cache_only, version,
-         suspicious_policy, untested_policy, pre_mutation, post_mutation,
+
+    sys.exit(do_run(argument, paths_to_mutate, disable_mutation_types, enable_mutation_types, runner,
+                  tests_dir, test_time_multiplier, test_time_base, swallow_output, use_coverage, 
+                  dict_synonyms, cache_only, pre_mutation, post_mutation, use_patch_file, paths_to_exclude,
+                  simple_output, no_progress))
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+def results():
+    """
+    Print the results.
+    """
+    print_result_cache()
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.argument('status', nargs=1, required=True)
+def result_ids(status):
+    """
+    Print the IDs of the specified mutant classes (separated by spaces).\n
+    result-ids survived (or any other of: killed,timeout,suspicious,skipped,untested)\n
+    """
+    if not status or status not in MUTANT_STATUSES:
+        raise click.BadArgumentUsage(f'The result-ids command needs a status class of mutants '
+                                     f'(one of : {set(MUTANT_STATUSES.keys())}) but was {status}')
+    print_result_ids_cache(status)
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.argument('mutation-id', nargs=1, required=True)
+@click.option('--backup/--no-backup', default=False)
+@click.option('--dict-synonyms')
+@config_from_setup_cfg(
+    dict_synonyms='',
+)
+def apply(mutation_id, backup, dict_synonyms):
+    """
+    Apply a mutation on disk.
+    """
+    do_apply(mutation_id, dict_synonyms, backup)
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.argument('id-or-file', nargs=1, required=False)
+@click.argument('only-filenames', nargs=1, required=False)  # TODO: this could be changed to be an option, but this would be a not backwards compatible change to the CLI
+@click.option('--dict-synonyms')
+@config_from_setup_cfg(
+    dict_synonyms='',
+)
+def show(id_or_file, only_filenames, dict_synonyms):
+    """
+    Show a mutation diff.
+    """
+    if not id_or_file:
+        print_result_cache()
+        sys.exit(0)
+
+    if id_or_file == 'all':
+        print_result_cache(show_diffs=True, dict_synonyms=dict_synonyms, print_only_filename=only_filenames)
+        sys.exit(0)
+
+    if os.path.isfile(id_or_file):
+        print_result_cache(show_diffs=True, only_this_file=id_or_file)
+        sys.exit(0)
+
+    print(get_unified_diff(id_or_file, dict_synonyms))
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.option('--dict-synonyms')
+@click.option('--suspicious-policy', type=click.Choice(['ignore', 'skipped', 'error', 'failure']), default='ignore')
+@click.option('--untested-policy', type=click.Choice(['ignore', 'skipped', 'error', 'failure']), default='ignore')
+@config_from_setup_cfg(
+    dict_synonyms='',
+)
+def junitxml(dict_synonyms, suspicious_policy, untested_policy):
+    """
+    Show a mutation diff with junitxml format.
+    """
+    print_result_cache_junitxml(dict_synonyms, suspicious_policy, untested_policy)
+    sys.exit(0)
+
+
+@climain.command(context_settings=dict(help_option_names=['-h', '--help']))
+@click.option('--dict-synonyms')
+@config_from_setup_cfg(
+    dict_synonyms='',
+)
+def html(dict_synonyms):
+    """
+    Generate a HTML report of surviving mutants.
+    """
+    create_html_report(dict_synonyms)
+    sys.exit(0)
+
+
+def do_run(argument, paths_to_mutate, disable_mutation_types, 
+         enable_mutation_types, runner, tests_dir, test_time_multiplier, test_time_base,
+         swallow_output, use_coverage, dict_synonyms, cache_only, pre_mutation, post_mutation,
          use_patch_file, paths_to_exclude, simple_output, no_progress):
     """return exit code, after performing an mutation test run.
 
     :return: the exit code from executing the mutation tests
     :rtype: int
     """
-    if version:
-        print("mutmut version {}".format(__version__))
-        return 0
-
     if use_coverage and use_patch_file:
         raise click.BadArgumentUsage("You can't combine --use-coverage and --use-patch")
 
@@ -180,57 +261,11 @@ def main(command, argument, argument2, paths_to_mutate, disable_mutation_types,
     if invalid_types:
         raise click.BadArgumentUsage(f"The following are not valid mutation types: {', '.join(sorted(invalid_types))}. Valid mutation types are: {', '.join(mutations_by_type.keys())}")
 
-    valid_commands = ['run', 'results', 'result-ids', 'apply', 'show', 'junitxml', 'html']
-    if command not in valid_commands:
-        raise click.BadArgumentUsage('{} is not a valid command, must be one of {}'.format(command, ', '.join(valid_commands)))
-
-    if command == 'results' and argument:
-        raise click.BadArgumentUsage('The {} command takes no arguments'.format(command))
-
     dict_synonyms = [x.strip() for x in dict_synonyms.split(',')]
 
-    if command in ('show', 'diff'):
-        if not argument:
-            print_result_cache()
-            return 0
-
-        if argument == 'all':
-            print_result_cache(show_diffs=True, dict_synonyms=dict_synonyms, print_only_filename=argument2)
-            return 0
-
-        if os.path.isfile(argument):
-            print_result_cache(show_diffs=True, only_this_file=argument)
-            return 0
-
-        print(get_unified_diff(argument, dict_synonyms))
-        return 0
-
     if use_coverage and not exists('.coverage'):
         raise FileNotFoundError('No .coverage file found. You must generate a coverage file to use this feature.')
 
-    if command == 'results':
-        print_result_cache()
-        return 0
-
-    if command == 'result-ids':
-        if not argument or argument not in MUTANT_STATUSES:
-            raise click.BadArgumentUsage(f'The {command} command needs a status class of mutants '
-                                         f'(one of : {set(MUTANT_STATUSES.keys())}) but was {argument}')
-        print_result_ids_cache(argument)
-        return 0
-
-    if command == 'junitxml':
-        print_result_cache_junitxml(dict_synonyms, suspicious_policy, untested_policy)
-        return 0
-
-    if command == 'html':
-        create_html_report(dict_synonyms)
-        return 0
-
-    if command == 'apply':
-        do_apply(argument, dict_synonyms, backup)
-        return 0
-
     if paths_to_mutate is None:
         paths_to_mutate = guess_paths_to_mutate()
 
@@ -329,9 +364,6 @@ Legend for output:
             assert use_patch_file
             covered_lines_by_filename = read_patch_data(use_patch_file)
 
-    if command != 'run':
-        raise click.BadArgumentUsage("Invalid command {}".format(command))
-
     mutations_by_file = {}
 
     paths_to_exclude = paths_to_exclude or ''
@@ -346,7 +378,6 @@ Legend for output:
         covered_lines_by_filename=covered_lines_by_filename,
         coverage_data=coverage_data,
         baseline_time_elapsed=baseline_time_elapsed,
-        backup=backup,
         dict_synonyms=dict_synonyms,
         using_testmon=using_testmon,
         cache_only=cache_only,


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 09fda7f..644155b 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -25,6 +25,7 @@ from mutmut import (
     python_source_files,
     read_coverage_data,
     MUTANT_STATUSES,
+    __version__,
 )
 from mutmut.__main__ import climain
 
@@ -86,6 +87,21 @@ def single_mutant_filesystem(tmpdir):
     mutmut.cache.db.schema = None
 
 
+@pytest.fixture
+def surviving_mutants_filesystem(tmpdir):
+    foo_py = """
+def foo(a, b):
+    result = a + b
+    return result
+"""
+
+    test_py = """
+def test_nothing(): assert True
+"""
+
+    create_filesystem(tmpdir, foo_py, test_py)
+
+
 def create_filesystem(tmpdir, file_to_mutate_contents, test_file_contents):
     test_dir = str(tmpdir)
     os.chdir(test_dir)
@@ -106,6 +122,10 @@ runner=python -m hammett -x
         f.write(test_file_contents)
 
 
+def test_print_version():
+    assert CliRunner().invoke(climain, ['version']).output.strip() == f'mutmut version {__version__}'
+
+
 def test_compute_return_code():
     # mock of Config for ease of testing
     class MockProgress(Progress):
@@ -253,6 +273,20 @@ def test_simple_apply(filesystem):
         assert f.read() != file_to_mutate_contents
 
 
+def test_simply_apply_with_backup(filesystem):
+    result = CliRunner().invoke(climain, ['run', '-s', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    print(repr(result.output))
+    assert result.exit_code == 0
+
+    result = CliRunner().invoke(climain, ['apply', '--backup', '1'], catch_exceptions=False)
+    print(repr(result.output))
+    assert result.exit_code == 0
+    with open(os.path.join(str(filesystem), 'foo.py')) as f:
+        assert f.read() != file_to_mutate_contents
+    with open(os.path.join(str(filesystem), 'foo.py.bak')) as f:
+        assert f.read() == file_to_mutate_contents
+
+
 def test_full_run_no_surviving_mutants(filesystem):
     result = CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
     print(repr(result.output))
@@ -520,3 +554,127 @@ def test_enable_and_disable_mutation_type_are_exclusive():
     )
     assert result.exception.code == 2
     assert "You can't combine --disable-mutation-types and --enable-mutation-types" in result.output
+
+
+def test_show(surviving_mutants_filesystem):
+    CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    result = CliRunner().invoke(climain, ['show'])
+    assert result.output.strip() == """
+To apply a mutant on disk:
+    mutmut apply <id>
+
+To show a mutant:
+    mutmut show <id>
+
+
+Survived 🙁 (2)
+
+---- foo.py (2) ----
+
+1-2
+""".strip()
+
+
+def test_show_single_id(surviving_mutants_filesystem):
+    CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    result = CliRunner().invoke(climain, ['show', '1'])
+    assert result.output.strip() == """
+--- foo.py
++++ foo.py
+@@ -1,5 +1,5 @@
+ 
+ def foo(a, b):
+-    result = a + b
++    result = a - b
+     return result
+""".strip()
+
+
+def test_show_all(surviving_mutants_filesystem):
+    CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    result = CliRunner().invoke(climain, ['show', 'all'])
+    assert result.output.strip() == """
+To apply a mutant on disk:
+    mutmut apply <id>
+
+To show a mutant:
+    mutmut show <id>
+
+
+Survived 🙁 (2)
+
+---- foo.py (2) ----
+
+# mutant 1
+--- foo.py
++++ foo.py
+@@ -1,5 +1,5 @@
+ 
+ def foo(a, b):
+-    result = a + b
++    result = a - b
+     return result
+ 
+
+# mutant 2
+--- foo.py
++++ foo.py
+@@ -1,5 +1,5 @@
+ 
+ def foo(a, b):
+-    result = a + b
++    result = None
+     return result
+""".strip()
+
+
+def test_show_for_file(surviving_mutants_filesystem):
+    CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    result = CliRunner().invoke(climain, ['show', 'foo.py'])
+    assert result.output.strip() == """
+To apply a mutant on disk:
+    mutmut apply <id>
+
+To show a mutant:
+    mutmut show <id>
+
+
+Survived 🙁 (2)
+
+---- foo.py (2) ----
+
+# mutant 1
+--- foo.py
++++ foo.py
+@@ -1,5 +1,5 @@
+ 
+ def foo(a, b):
+-    result = a + b
++    result = a - b
+     return result
+ 
+
+# mutant 2
+--- foo.py
++++ foo.py
+@@ -1,5 +1,5 @@
+ 
+ def foo(a, b):
+-    result = a + b
++    result = None
+     return result
+""".strip()
+
+
+def test_html_output(surviving_mutants_filesystem):
+    result = CliRunner().invoke(climain, ['run', '--paths-to-mutate=foo.py', "--test-time-base=15.0"], catch_exceptions=False)
+    print(repr(result.output))
+    result = CliRunner().invoke(climain, ['html'])
+    assert os.path.isfile("html/index.html")
+    with open("html/index.html") as f:
+        assert f.read() == (
+            '<h1>Mutation testing report</h1>'
+            'Killed 0 out of 2 mutants'
+            '<table><thead><tr><th>File</th><th>Total</th><th>Killed</th><th>% killed</th><th>Survived</th></thead>'
+            '<tr><td><a href="foo.py.html">foo.py</a></td><td>2</td><td>0</td><td>0.00</td><td>2</td>'
+            '</table></body></html>')