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
```

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

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

```diff
@@            Coverage Diff             @@
##           master     #509      +/-   ##
==========================================
- Coverage   91.90%   88.21%   -3.69%     
==========================================
  Files          43       43              
  Lines        2470     2461       -9     
  Branches      398      400       +2     
==========================================
- Hits         2270     2171      -99     
- Misses        160      247      +87     
- Partials       40       43       +3     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/509?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `97.40% <93.75%> (-1.27%)` | :arrow_down: |
| [src/repobee\_plug/cli.py](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkucHk=) | `100.00% <100.00%> (ø)` | |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/509/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/509/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NsaS9wYXJzaW5nLnB5) | `87.57% <0.00%> (-4.35%)` | :arrow_down: |
| [src/\_repobee/git.py](https://codecov.io/gh/repobee/repobee/pull/509/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/509/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NvbW1hbmQvcmVwb3MucHk=) | `92.62% <0.00%> (-2.46%)` | :arrow_down: |
| [src/\_repobee/ext/query.py](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9xdWVyeS5weQ==) | `48.48% <0.00%> (-1.52%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/github.py](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9naXRodWIucHk=) | `90.29% <0.00%> (-0.11%)` | :arrow_down: |
| [src/repobee\_plug/\_containers.py](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fY29udGFpbmVycy5weQ==) | `93.08% <0.00%> (-0.05%)` | :arrow_down: |
| ... and [4 more](https://codecov.io/gh/repobee/repobee/pull/509/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/509?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/509?src=pr&el=footer). Last update [cbfc528...0f96ae3](https://codecov.io/gh/repobee/repobee/pull/509?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-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."""