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).

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

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

pip install -e .[TEST]
parse_log_pytest
requirements.txt
pytest>=4.0.0 pytest-cov>=2.6.1 pytest-mock codecov pytest
3.8
requirements/app.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
appdirs==1.4.4
bandit==1.7.10
black==24.8.0
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
codecov==2.1.13
colored==1.4.4
coverage==7.6.1
cryptography==44.0.2
daiquiri==3.2.5.1
Deprecated==1.2.18
exceptiongroup==1.2.2
flake8==7.1.2
idna==3.10
iniconfig==2.1.0
markdown-it-py==3.0.0
mccabe==0.7.0
mdurl==0.1.2
mypy-extensions==1.0.0
packaging==24.2
pathspec==0.12.1
pbr==6.1.1
platformdirs==4.3.6
pluggy==1.5.0
pycodestyle==2.12.1
pycparser==2.22
pyflakes==3.2.0
PyGithub==2.6.1
Pygments==2.19.1
PyJWT==2.9.0
PyNaCl==1.5.0
pytest==8.3.5
pytest-cov==5.0.0
pytest-mock==3.14.0
python-gitlab==1.15.0
python-json-logger==3.3.0
PyYAML==6.0.2
-e git+https://github.com/repobee/repobee.git@cbfc528f133d099530a7ad51975843226c4336b8#egg=repobee
requests==2.32.3
rich==14.0.0
six==1.17.0
stevedore==5.3.0
tomli==2.2.1
typing_extensions==4.13.0
urllib3==2.2.3
wrapt==1.17.2

name: repobee
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=24.2=py38h06a4308_0
  - python=3.8.20=he870216_0
  - readline=8.2=h5eee18b_0
  - setuptools=75.1.0=py38h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.44.0=py38h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - appdirs==1.4.4
      - bandit==1.7.10
      - black==24.8.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colored==1.4.4
      - coverage==7.6.1
      - cryptography==44.0.2
      - daiquiri==3.2.5.1
      - deprecated==1.2.18
      - exceptiongroup==1.2.2
      - flake8==7.1.2
      - idna==3.10
      - iniconfig==2.1.0
      - markdown-it-py==3.0.0
      - mccabe==0.7.0
      - mdurl==0.1.2
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pathspec==0.12.1
      - pbr==6.1.1
      - platformdirs==4.3.6
      - pluggy==1.5.0
      - pycodestyle==2.12.1
      - pycparser==2.22
      - pyflakes==3.2.0
      - pygithub==2.6.1
      - pygments==2.19.1
      - pyjwt==2.9.0
      - pynacl==1.5.0
      - pytest==8.3.5
      - pytest-cov==5.0.0
      - pytest-mock==3.14.0
      - python-gitlab==1.15.0
      - python-json-logger==3.3.0
      - pyyaml==6.0.2
      - repobee==3.0.0a2
      - requests==2.32.3
      - rich==14.0.0
      - six==1.17.0
      - stevedore==5.3.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - urllib3==2.2.3
      - wrapt==1.17.2
prefix: /opt/conda/envs/repobee

tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_positional_arguments
tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_raises_on_non_hook_public_method tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_happy_path tests/unit_tests/repobee_plug/test_pluginmeta.py::TestPluginInheritance::test_with_private_methods tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_defaults tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_with_metadata tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_generated_parser tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_configuration tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_raises_when_non_configurable_value_is_configured tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeExtensionCommand::test_override_opt_names tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_add_required_option_to_config_show tests/unit_tests/repobee_plug/test_pluginmeta.py::TestDeclarativeCommandExtension::test_raises_when_command_and_command_extension_are_subclassed_together
src/repobee_plug/_pluginmeta.py src/repobee_plug/cli.py
src/repobee_plug/_pluginmeta.py src/repobee_plug/cli.py