Repository: repobee/repobee

Issue:
Add positional arguments to declarative extension commands
We need to add positional arguments to the declarative extension commands. It could look like this:

```python
# command.py
import repobee_plug as plug
class Greeting(plug.Plugin, plug.cli.Command):
    name = plug.cli.Positional()

    def command_callback(self, args, api):
        print(args.name)
```
And can be called like so:
```
$ repobee -p command.py greeting Simon
Simon
```

Comments:

Patch:
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index 01981c8..ae12f8f 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -2,7 +2,7 @@ import argparse
 import daiquiri
 import functools
 
-from typing import List, Tuple, Callable, Mapping, Any
+from typing import List, Tuple, Callable, Mapping, Any, Union
 
 from repobee_plug import _exceptions
 from repobee_plug import _corehooks
@@ -90,14 +90,17 @@ class _PluginMeta(type):
         }
 
 
-def _extract_cli_options(attrdict) -> List[Tuple[str, cli.Option]]:
+def _extract_cli_options(
+    attrdict,
+) -> List[Tuple[str, Union[cli.Option, cli.Positional]]]:
     """Return any members that are CLI options as a list of tuples on the form
-    (member_name, option).
+    (member_name, option). Other types of CLI arguments, such as positionals,
+    are converted to :py:class:`~cli.Option`s.
     """
     return [
         (key, value)
         for key, value in attrdict.items()
-        if isinstance(value, cli.Option)
+        if isinstance(value, (cli.Option, cli.Positional))
     ]
 
 
@@ -127,7 +130,9 @@ def _attach_options(config, show_all_opts, parser, plugin: "Plugin"):
     opts = _extract_cli_options(plugin.__class__.__dict__)
     for (name, opt) in opts:
         configured_value = config_section.get(name)
-        if configured_value and not opt.configurable:
+        if configured_value and not (
+            hasattr(opt, "configurable") and opt.configurable
+        ):
             raise _exceptions.PlugError(
                 f"Plugin '{plugin.plugin_name}' does not allow "
                 f"'{name}' to be configured"
@@ -168,7 +173,7 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
 
 def _add_option(
     name: str,
-    opt: cli.Option,
+    opt: Union[cli.Positional, cli.Option],
     configured_value: str,
     show_all_opts: bool,
     parser: argparse.ArgumentParser,
@@ -177,26 +182,32 @@ def _add_option(
     args = []
     kwargs = opt.argparse_kwargs or {}
 
-    if opt.short_name:
-        args.append(opt.short_name)
+    if opt.converter:
+        kwargs["type"] = opt.converter
 
-    if opt.long_name:
-        args.append(opt.long_name)
-    else:
-        args.append(f"--{name.replace('_', '-')}")
-
-    kwargs["type"] = opt.converter
-    # configured value takes precedence over default
-    kwargs["default"] = configured_value or opt.default
-    kwargs["dest"] = name
-    # required opts become not required if configured
-    kwargs["required"] = not configured_value and opt.required
     kwargs["help"] = (
         argparse.SUPPRESS
         if (configured_value and not show_all_opts)
         else opt.help or ""
     )
 
+    if isinstance(opt, cli.Option):
+        if opt.short_name:
+            args.append(opt.short_name)
+
+        if opt.long_name:
+            args.append(opt.long_name)
+        else:
+            args.append(f"--{name.replace('_', '-')}")
+
+        kwargs["dest"] = name
+        # configured value takes precedence over default
+        kwargs["default"] = configured_value or opt.default
+        # required opts become not required if configured
+        kwargs["required"] = not configured_value and opt.required
+    elif isinstance(opt, cli.Positional):
+        args.append(name)
+
     parser.add_argument(*args, **kwargs)
 
 
diff --git a/src/repobee_plug/cli.py b/src/repobee_plug/cli.py
index 2359850..6192fa7 100644
--- a/src/repobee_plug/cli.py
+++ b/src/repobee_plug/cli.py
@@ -19,6 +19,11 @@ Option = collections.namedtuple(
 )
 Option.__new__.__defaults__ = (None,) * len(Option._fields)
 
+Positional = collections.namedtuple(
+    "Positional", ["help", "converter", "argparse_kwargs"]
+)
+Positional.__new__.__defaults__ = (None,) * len(Positional._fields)
+
 
 class CommandExtension:
     """Mixin class for use with the Plugin class. Marks the extending class as


Test patch:
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 6f5afea..475b693 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -267,6 +267,25 @@ class TestDeclarativeExtensionCommand:
         assert short_opt_args.name == name
         assert long_opt_args.name == name
 
+    def test_positional_arguments(self):
+        class Greeting(plug.Plugin, plug.cli.Command):
+            name = plug.cli.Positional()
+            age = plug.cli.Positional(converter=int)
+
+            def command_callback(self, args, api):
+                pass
+
+        ext_cmd = Greeting("g").create_extension_command()
+        parser = argparse.ArgumentParser()
+        ext_cmd.parser(config={}, show_all_opts=False, parser=parser)
+
+        name = "Alice"
+        age = 33
+        parsed_args = parser.parse_args(f"{name} {age}".split())
+
+        assert parsed_args.name == name
+        assert parsed_args.age == age
+
 
 class TestDeclarativeCommandExtension:
     """Test creating command extensions to existing commands."""