Repository: repobee/repobee

Issue:
Automatically assign processed CLI arguments to plugin command/command extension instance
As it's inconvenient to to pass the parsed CLI arguments to _all_ hooks, some plugins will simply have to access the arguments through the `handle_processed_args` or `handle_parsed_args` hooks. This could be implemented automatically for commands and command extensions.

This also means that the `args` argument can be dropped from the `command` function.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=h1) Report
> Merging [#526](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=desc) into [master](https://codecov.io/gh/repobee/repobee/commit/9b9bed1bcb1e0da6ba5bb45fb996669d4cd14a32&el=desc) will **decrease** coverage by `3.64%`.
> The diff coverage is `89.65%`.

[![Impacted file tree graph](https://codecov.io/gh/repobee/repobee/pull/526/graphs/tree.svg?width=650&height=150&src=pr&token=5vOKgkphZe)](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #526      +/-   ##
==========================================
- Coverage   92.21%   88.56%   -3.65%     
==========================================
  Files          47       47              
  Lines        2504     2510       +6     
  Branches      407      409       +2     
==========================================
- Hits         2309     2223      -86     
- Misses        152      239      +87     
- Partials       43       48       +5     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/\_repobee/ext/javac.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9qYXZhYy5weQ==) | `42.85% <0.00%> (-3.81%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/configwizard.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9jb25maWd3aXphcmQucHk=) | `97.29% <50.00%> (ø)` | |
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `94.59% <95.45%> (+0.08%)` | :arrow_up: |
| [src/\_repobee/cli/dispatch.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9kaXNwYXRjaC5weQ==) | `88.88% <100.00%> (ø)` | |
| [src/repobee\_plug/cli/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/repobee\_plug/cli/args.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvYXJncy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9naXRsYWIucHk=) | `63.86% <0.00%> (-28.56%)` | :arrow_down: |
| [src/\_repobee/cli/parsing.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9wYXJzaW5nLnB5) | `87.58% <0.00%> (-5.23%)` | :arrow_down: |
| [src/\_repobee/git.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2dpdC5weQ==) | `96.62% <0.00%> (-3.38%)` | :arrow_down: |
| [src/\_repobee/command/repos.py](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NvbW1hbmQvcmVwb3MucHk=) | `91.80% <0.00%> (-3.28%)` | :arrow_down: |
| ... and [8 more](https://codecov.io/gh/repobee/repobee/pull/526/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=footer). Last update [9b9bed1...30518d2](https://codecov.io/gh/repobee/repobee/pull/526?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/_repobee/cli/dispatch.py b/src/_repobee/cli/dispatch.py
index 330594e..59374dc 100644
--- a/src/_repobee/cli/dispatch.py
+++ b/src/_repobee/cli/dispatch.py
@@ -47,7 +47,7 @@ def dispatch_command(
     is_ext_command = "_extension_command" in args
     if is_ext_command:
         ext_cmd = args._extension_command
-        res = ext_cmd.callback(args, api)
+        res = ext_cmd.callback(api)
         hook_results = {ext_cmd.name: [res]} if res else hook_results
     else:
         category = args.category
diff --git a/src/_repobee/ext/defaults/configwizard.py b/src/_repobee/ext/defaults/configwizard.py
index 21a86c8..eaab7d8 100644
--- a/src/_repobee/ext/defaults/configwizard.py
+++ b/src/_repobee/ext/defaults/configwizard.py
@@ -30,8 +30,8 @@ class Wizard(plug.Plugin, plug.cli.Command):
         ),
     )
 
-    def command(self, args: argparse.Namespace, api: plug.API) -> None:
-        return callback(args, api)
+    def command(self, api: plug.API) -> None:
+        return callback(self.args, api)
 
 
 def callback(args: argparse.Namespace, api: plug.API) -> None:
diff --git a/src/_repobee/ext/javac.py b/src/_repobee/ext/javac.py
index 9842030..7d2b687 100644
--- a/src/_repobee/ext/javac.py
+++ b/src/_repobee/ext/javac.py
@@ -20,7 +20,6 @@ and specifically the more advanced use of the ``clone_parser_hook`` and
 """
 import subprocess
 import sys
-import argparse
 import pathlib
 from typing import Union, Iterable, Tuple
 
@@ -46,9 +45,6 @@ class JavacCloneHook(plug.Plugin, plug.cli.CommandExtension):
         argparse_kwargs={"nargs": "+"},
     )
 
-    def handle_processed_args(self, args: argparse.Namespace):
-        self.ignore = args.javac_ignore or []
-
     def post_clone(self, path: pathlib.Path, api: plug.API) -> plug.Result:
         """Run ``javac`` on all .java files in the repo.
 
@@ -58,10 +54,11 @@ class JavacCloneHook(plug.Plugin, plug.cli.CommandExtension):
         Returns:
             a Result specifying the outcome.
         """
+        ignore = self.javac_ignore or []
         java_files = [
             str(file)
             for file in util.find_files_by_extension(path, ".java")
-            if file.name not in self.ignore
+            if file.name not in ignore
         ]
 
         if not java_files:
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index 198aba3..8316a60 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -1,4 +1,5 @@
 import argparse
+import itertools
 import daiquiri
 import functools
 
@@ -55,6 +56,8 @@ class _PluginMeta(type):
         if cli.Command in bases:
             ext_cmd_func = _generate_command_func(attrdict)
             attrdict[ext_cmd_func.__name__] = ext_cmd_func
+            handle_processed_args = _generate_handle_processed_args_func()
+            attrdict[handle_processed_args.__name__] = handle_processed_args
         elif cli.CommandExtension in bases:
             if "__settings__" not in attrdict:
                 raise _exceptions.PlugError(
@@ -62,6 +65,8 @@ class _PluginMeta(type):
                 )
             ext_cmd_func = _generate_command_extension_func()
             attrdict[ext_cmd_func.__name__] = ext_cmd_func
+            handle_processed_args = _generate_handle_processed_args_func()
+            attrdict[handle_processed_args.__name__] = handle_processed_args
 
         methods = cls._extract_public_methods(attrdict)
         cls._check_names(methods)
@@ -175,6 +180,41 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
     return create_extension_command
 
 
+def _generate_handle_processed_args_func():
+    def handle_processed_args(self, args):
+        self.args = args
+        cli_args = [
+            (k, v)
+            for k, v in self.__class__.__dict__.items()
+            if cli.is_cli_arg(v)
+        ]
+        flattened_args = itertools.chain.from_iterable(
+            map(_flatten_arg, cli_args)
+        )
+
+        for name, arg in flattened_args:
+            dest = (
+                name
+                if "dest" not in arg.argparse_kwargs
+                else arg.argparse_kwargs["dest"]
+            )
+            if dest in args:
+                parsed_arg = getattr(args, dest)
+                setattr(self, dest, parsed_arg)
+
+    return handle_processed_args
+
+
+def _flatten_arg(arg_tup):
+    name, arg = arg_tup
+    assert cli.is_cli_arg(arg)
+
+    if isinstance(arg, MutuallyExclusiveGroup):
+        return itertools.chain.from_iterable(map(_flatten_arg, arg.options))
+    else:
+        return [arg_tup]
+
+
 def _add_option(
     name: str,
     opt: Union[Option, MutuallyExclusiveGroup],
diff --git a/src/repobee_plug/cli/__init__.py b/src/repobee_plug/cli/__init__.py
index ff8484b..9ed56eb 100644
--- a/src/repobee_plug/cli/__init__.py
+++ b/src/repobee_plug/cli/__init__.py
@@ -1,6 +1,12 @@
 from .settings import command_settings, command_extension_settings
 from .categorization import category
-from .args import option, positional, mutually_exclusive_group, ArgumentType
+from .args import (
+    option,
+    positional,
+    mutually_exclusive_group,
+    ArgumentType,
+    is_cli_arg,
+)
 from .commandmarkers import Command, CommandExtension
 
 from ._corecommand import _CoreCommand
@@ -18,4 +24,5 @@ __all__ = [
     "Command",
     "CommandExtension",
     "CoreCommand",
+    "is_cli_arg",
 ]
diff --git a/src/repobee_plug/cli/args.py b/src/repobee_plug/cli/args.py
index 40030f6..e44431a 100644
--- a/src/repobee_plug/cli/args.py
+++ b/src/repobee_plug/cli/args.py
@@ -31,6 +31,17 @@ MutuallyExclusiveGroup = collections.namedtuple(
 )
 
 
+def is_cli_arg(obj: Any) -> bool:
+    """Determine if an object is a CLI argument.
+
+    Args:
+        obj: An object.
+    Returns:
+        True if the object is an instance of a CLI argument class.
+    """
+    return isinstance(obj, (Option, MutuallyExclusiveGroup))
+
+
 def option(
     short_name: Optional[str] = None,
     long_name: Optional[str] = None,
@@ -56,10 +67,10 @@ def option(
             name = plug.cli.option(help="Your name.")
             age = plug.cli.option(converter=int, help="Your age.")
 
-            def command(self, args, api):
+            def command(self, api):
                 print(
-                    f"Hello, my name is {args.name} "
-                    f"and I am {args.age} years old"
+                    f"Hello, my name is {self.name} "
+                    f"and I am {self.age} years old"
                 )
 
     This command can then be called like so:
@@ -122,10 +133,10 @@ def positional(
             name = plug.cli.Positional(help="Your name.")
             age = plug.cli.Positional(converter=int, help="Your age.")
 
-            def command(self, args, api):
+            def command(self, api):
                 print(
-                    f"Hello, my name is {args.name} "
-                    f"and I am {args.age} years old"
+                    f"Hello, my name is {self.name} "
+                    f"and I am {self.age} years old"
                 )
 
     This command can then be called like so:


Test patch:
diff --git a/tests/unit_tests/repobee/test_cli.py b/tests/unit_tests/repobee/test_cli.py
index 2b696f6..0ba9ace 100644
--- a/tests/unit_tests/repobee/test_cli.py
+++ b/tests/unit_tests/repobee/test_cli.py
@@ -852,7 +852,9 @@ class TestExtensionCommands:
             parsed_args, None, EMPTY_PATH, [ext_command]
         )
 
-        mock_callback.assert_called_once_with(parsed_args, None)
+        calls = mock_callback.mock_calls
+        assert len(calls) == 1
+        assert calls[0] == mock.call(None)
 
     def test_dispatch_ext_command_that_requires_api(
         self,
@@ -883,7 +885,9 @@ class TestExtensionCommands:
             parsed_args, api_instance_mock, EMPTY_PATH, [ext_command]
         )
 
-        mock_callback.assert_called_once_with(parsed_args, api_instance_mock)
+        calls = mock_callback.mock_calls
+        assert len(calls) == 1
+        assert calls[0] == mock.call(api_instance_mock)
 
     def test_parse_ext_command_that_requires_base_parsers(self):
         """The query command requires the students and repo names parsers."""
diff --git a/tests/unit_tests/repobee/test_main.py b/tests/unit_tests/repobee/test_main.py
index 367b3b4..14e30de 100644
--- a/tests/unit_tests/repobee/test_main.py
+++ b/tests/unit_tests/repobee/test_main.py
@@ -446,7 +446,7 @@ class TestRun:
     """Tests for the run function."""
 
     class Workdir(plug.Plugin, plug.cli.Command):
-        def command(self, args, api):
+        def command(self, api):
             return plug.Result(
                 name="workdir",
                 msg="workdir",
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index eb5daa2..54c8cbc 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -124,8 +124,8 @@ class TestDeclarativeExtensionCommand:
             name = plug.cli.option(help="your name", required=True)
             age = plug.cli.option(converter=int, help="your age", default=30)
 
-            def command(self, args, api):
-                print(f"My name is {args.name} and I am {args.age} years old")
+            def command(self, api):
+                print(f"My name is {self.name} and I am {self.age} years old")
 
         return Greeting
 
@@ -167,7 +167,7 @@ class TestDeclarativeExtensionCommand:
                 requires_api=expected_requires_api,
             )
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         plugin_instance = ExtCommand("greeting")
@@ -199,7 +199,7 @@ class TestDeclarativeExtensionCommand:
                 help="Your name.", required=True, configurable=True
             )
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         plugin_name = "greeting"
@@ -221,7 +221,7 @@ class TestDeclarativeExtensionCommand:
         class Greeting(plug.Plugin, plug.cli.Command):
             name = plug.cli.option(help="Your name.", required=True)
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         plugin_name = "greeting"
@@ -254,7 +254,7 @@ class TestDeclarativeExtensionCommand:
                 required=True,
             )
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         ext_cmd = Greeting("g").create_extension_command()
@@ -273,7 +273,7 @@ class TestDeclarativeExtensionCommand:
             name = plug.cli.positional()
             age = plug.cli.positional(converter=int)
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         ext_cmd = Greeting("g").create_extension_command()
@@ -301,7 +301,7 @@ class TestDeclarativeExtensionCommand:
                 __required__=True,
             )
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         ext_cmd = Greeting("g").create_extension_command()
@@ -343,7 +343,7 @@ class TestDeclarativeExtensionCommand:
                 __required__=True,
             )
 
-            def command(self, args, api):
+            def command(self, api):
                 pass
 
         ext_cmd = Greeting("g").create_extension_command()
@@ -365,12 +365,12 @@ class TestDeclarativeExtensionCommand:
             name = plug.cli.positional()
             age = plug.cli.positional(converter=int)
 
-            def command(self, args, api):
+            def command(self, api):
                 return plug.Result(
                     name=self.plugin_name,
                     msg="Nice!",
                     status=plug.Status.SUCCESS,
-                    data={"name": args.name, "age": args.age},
+                    data={"name": self.name, "age": self.age},
                 )
 
         name = "Bob"
@@ -399,10 +399,10 @@ class TestDeclarativeExtensionCommand:
             __settings__ = plug.cli.command_settings(action=category.hello)
             name = plug.cli.positional()
 
-            def command(self, args, api):
+            def command(self, api):
                 return plug.Result(
                     name=self.plugin_name,
-                    msg=f"Hello {args.name}",
+                    msg=f"Hello {self.name}",
                     status=plug.Status.SUCCESS,
                 )
 
@@ -410,10 +410,10 @@ class TestDeclarativeExtensionCommand:
             __settings__ = plug.cli.command_settings(action=category.bye)
             name = plug.cli.positional()
 
-            def command(self, args, api):
+            def command(self, api):
                 return plug.Result(
                     name=self.plugin_name,
-                    msg=f"Bye {args.name}",
+                    msg=f"Bye {self.name}",
                     status=plug.Status.SUCCESS,
                 )
 
@@ -442,7 +442,7 @@ class TestDeclarativeExtensionCommand:
                     action=category.greetings, category=category
                 )
 
-                def command(self, args, api):
+                def command(self, api):
                     pass
 
         assert (
@@ -450,6 +450,42 @@ class TestDeclarativeExtensionCommand:
             "'action' is an Action object"
         ) in str(exc_info.value)
 
+    def test_parsed_args_are_added_to_self(self):
+        """Parsed cli arguments should automatically be added to the plugin
+        object instance.
+        """
+
+        class Ext(plug.Plugin, plug.cli.Command):
+            name = plug.cli.option()
+            age = plug.cli.positional(converter=int)
+            tolerance = plug.cli.mutually_exclusive_group(
+                high=plug.cli.option(
+                    argparse_kwargs=dict(action="store_true")
+                ),
+                low=plug.cli.option(argparse_kwargs=dict(action="store_true")),
+                __required__=True,
+            )
+
+            def command(self, api):
+                return plug.Result(
+                    name="ext",
+                    msg="",
+                    status=plug.Status.SUCCESS,
+                    data={"instance": self},
+                )
+
+        name = "Eve"
+        age = 22
+        instance = repobee.run(
+            f"ext {age} --name {name} --high".split(), plugins=[Ext]
+        )["ext"][0].data["instance"]
+
+        assert instance.name == name
+        assert instance.age == age
+        assert instance.high
+        assert not instance.low
+        assert isinstance(instance.args, argparse.Namespace)
+
 
 class TestDeclarativeCommandExtension:
     """Test creating command extensions to existing commands."""