Repository: repobee/repobee

Issue:
Refactor Option, Positional and MutuallyExclusiveGroup to be functions
If these are rewritten as functions, the user doesn't need to care much which data structure is used, and we can put extra information into them.

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

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

```diff
@@            Coverage Diff             @@
##           master     #514      +/-   ##
==========================================
- Coverage   91.86%   88.17%   -3.70%     
==========================================
  Files          43       43              
  Lines        2520     2494      -26     
  Branches      410      409       -1     
==========================================
- Hits         2315     2199     -116     
- Misses        162      249      +87     
- Partials       43       46       +3     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `93.18% <83.33%> (-0.08%)` | :arrow_down: |
| [src/\_repobee/ext/defaults/configwizard.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9jb25maWd3aXphcmQucHk=) | `97.29% <100.00%> (ø)` | |
| [src/repobee\_plug/cli.py](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkucHk=) | `94.61% <100.00%> (-0.49%)` | :arrow_down: |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/514/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/514/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/514/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/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2NvbW1hbmQvcmVwb3MucHk=) | `91.80% <0.00%> (-3.28%)` | :arrow_down: |
| [src/\_repobee/ext/query.py](https://codecov.io/gh/repobee/repobee/pull/514/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/514/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9kZWZhdWx0cy9naXRodWIucHk=) | `90.29% <0.00%> (-0.11%)` | :arrow_down: |
| ... and [5 more](https://codecov.io/gh/repobee/repobee/pull/514/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/514?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/514?src=pr&el=footer). Last update [2f3dc41...262d3bb](https://codecov.io/gh/repobee/repobee/pull/514?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/_repobee/ext/defaults/configwizard.py b/src/_repobee/ext/defaults/configwizard.py
index d1c0e3c..16707cc 100644
--- a/src/_repobee/ext/defaults/configwizard.py
+++ b/src/_repobee/ext/defaults/configwizard.py
@@ -20,7 +20,7 @@ LOGGER = daiquiri.getLogger(__file__)
 
 
 class Wizard(plug.Plugin, plug.cli.Command):
-    __settings__ = plug.cli.CommandSettings(
+    __settings__ = plug.cli.command_settings(
         category=plug.cli.CoreCommand.config,
         help="Interactive configuration wizard to set up the config file.",
         description=(
diff --git a/src/repobee_plug/_pluginmeta.py b/src/repobee_plug/_pluginmeta.py
index d07ef9c..ce6bd42 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -95,9 +95,7 @@ class _PluginMeta(type):
 
 def _extract_cli_options(
     attrdict,
-) -> List[
-    Tuple[str, Union[cli.Option, cli.Positional, cli.MutuallyExclusiveGroup]]
-]:
+) -> List[Tuple[str, Union[cli._Option, cli._MutuallyExclusiveGroup]]]:
     """Return any members that are CLI options as a list of tuples on the form
     (member_name, option). Other types of CLI arguments, such as positionals,
     are converted to :py:class:`~cli.Option`s.
@@ -105,9 +103,7 @@ def _extract_cli_options(
     return [
         (key, value)
         for key, value in attrdict.items()
-        if isinstance(
-            value, (cli.Option, cli.Positional, cli.MutuallyExclusiveGroup)
-        )
+        if isinstance(value, (cli._Option, cli._MutuallyExclusiveGroup))
     ]
 
 
@@ -160,7 +156,7 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
     """
 
     def create_extension_command(self):
-        settings = attrdict.get("__settings__") or cli.CommandSettings()
+        settings = attrdict.get("__settings__") or cli.command_settings()
         if settings.config_section_name:
             self.__config_section__ = settings.config_section_name
 
@@ -181,13 +177,13 @@ def _generate_command_func(attrdict: Mapping[str, Any]) -> Callable:
 
 def _add_option(
     name: str,
-    opt: Union[cli.Positional, cli.Option, cli.MutuallyExclusiveGroup],
+    opt: Union[cli._Option, cli._MutuallyExclusiveGroup],
     configured_value: str,
     show_all_opts: bool,
     parser: argparse.ArgumentParser,
 ) -> None:
     """Add an option to the parser based on the cli option."""
-    if isinstance(opt, cli.MutuallyExclusiveGroup):
+    if isinstance(opt, cli._MutuallyExclusiveGroup):
         mutex_parser = parser.add_mutually_exclusive_group(
             required=opt.required
         )
@@ -201,8 +197,9 @@ def _add_option(
             )
         return
 
+    assert isinstance(opt, cli._Option)
     args = []
-    kwargs = opt.argparse_kwargs or {}
+    kwargs = opt.argparse_kwargs
 
     if opt.converter:
         kwargs["type"] = opt.converter
@@ -213,7 +210,7 @@ def _add_option(
         else opt.help or ""
     )
 
-    if isinstance(opt, cli.Option):
+    if opt.argument_type == cli.ArgumentType.OPTION:
         if opt.short_name:
             args.append(opt.short_name)
 
@@ -227,7 +224,7 @@ def _add_option(
         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):
+    elif opt.argument_type == cli.ArgumentType.POSITIONAL:
         args.append(name)
 
     parser.add_argument(*args, **kwargs)
diff --git a/src/repobee_plug/cli.py b/src/repobee_plug/cli.py
index 8918837..99aa139 100644
--- a/src/repobee_plug/cli.py
+++ b/src/repobee_plug/cli.py
@@ -1,26 +1,36 @@
 """Plugin functionality for creating extensions to the RepoBee CLI."""
 import abc
 import collections
+import enum
 import itertools
-from typing import List, Tuple, Optional, Set, Mapping, Iterable
+from typing import List, Tuple, Optional, Set, Mapping, Iterable, Any, Callable
 
 from repobee_plug import _containers
 
+
 __all__ = [
-    "Option",
-    "Positional",
-    "MutuallyExclusiveGroup",
+    "option",
+    "positional",
+    "mutually_exclusive_group",
+    "command_settings",
+    "command_extension_settings",
     "Command",
     "CommandExtension",
     "Action",
     "Category",
     "CoreCommand",
-    "CommandSettings",
 ]
 
 from repobee_plug._containers import ImmutableMixin
 
-Option = collections.namedtuple(
+
+class ArgumentType(enum.Enum):
+    OPTION = "Option"
+    POSITIONAL = "Positional"
+    MUTEX_GROUP = "Mutex"
+
+
+_Option = collections.namedtuple(
     "Option",
     [
         "short_name",
@@ -30,20 +40,41 @@ Option = collections.namedtuple(
         "converter",
         "required",
         "default",
+        "argument_type",
         "argparse_kwargs",
     ],
 )
-Option.__new__.__defaults__ = (None,) * len(Option._fields)
+_Option.__new__.__defaults__ = (None,) * len(_Option._fields)
 
-Positional = collections.namedtuple(
-    "Positional", ["help", "converter", "argparse_kwargs"]
+_CommandSettings = collections.namedtuple(
+    "_CommandSettings",
+    [
+        "action_name",
+        "category",
+        "help",
+        "description",
+        "requires_api",
+        "base_parsers",
+        "config_section_name",
+    ],
 )
-Positional.__new__.__defaults__ = (None,) * len(Positional._fields)
 
 
-class CommandSettings(_containers.ImmutableMixin):
-    """Settings for a :py:class:`Command`, that can be provided in the
-    ``__settings__`` attribute.
+_CommandExtensionSettings = collections.namedtuple(
+    "_CommandExtensionSettings", ["actions", "config_section_name"]
+)
+
+
+def command_settings(
+    action_name: Optional[str] = None,
+    category: Optional["CoreCommand"] = None,
+    help: str = "",
+    description: str = "",
+    requires_api: bool = False,
+    base_parsers: Optional[List[_containers.BaseParser]] = None,
+    config_section_name: Optional[str] = None,
+) -> _CommandSettings:
+    """Create a settings object for a :py:class:`Command`.
 
     Example usage:
 
@@ -53,7 +84,7 @@ class CommandSettings(_containers.ImmutableMixin):
         import repobee_plug as plug
 
         class Ext(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.CommandSettings(
+            __settings__ = plug.cli.command_settings(
                 action_name="hello",
                 category=plug.cli.CoreCommand.config,
             )
@@ -67,116 +98,207 @@ class CommandSettings(_containers.ImmutableMixin):
 
         $ repobee -p ext.py config hello
         Hello, world!
+
+    Args:
+        action_name: The name of the action that the command will be
+            available under. Defaults to the name of the plugin class.
+        category: The category to place this command in. If not specified,
+            then the command will be top-level (i.e. uncategorized).
+        help: A help section for the command. This appears when listing the
+            help section of the command's category.
+        description: A help section for the command. This appears when
+            listing the help section for the command itself.
+        requires_api: If True, a platform API will be insantiated and
+            passed to the command function.
+        base_parsers: A list of base parsers to add to the command.
+        config_section_name: The name of the configuration section the
+            command should look for configurable options in. Defaults
+            to the name of the plugin the command is defined in.
+    """
+    return _CommandSettings(
+        action_name=action_name,
+        category=category,
+        help=help,
+        description=description,
+        requires_api=requires_api,
+        base_parsers=base_parsers,
+        config_section_name=config_section_name,
+    )
+
+
+def command_extension_settings(
+    actions: List["Action"], config_section_name: Optional[str] = None
+) -> _CommandExtensionSettings:
+    """Settings for a :py:class:`CommandExtension`.
+
+    Args:
+        actions: A list of actions to extend.
+        config_section_name: Name of the configuration section that the
+            command extension will fetch configuration values from.
+            Defaults to the name of the plugin in which the extension is
+            defined.
+    Returns:
+        A wrapper object for settings.
     """
 
-    action_name: Optional[str]
-    category: Optional["CoreCommand"]
-    help: str
-    description: str
-    requires_api: bool
-    base_parsers: Optional[List[_containers.BaseParser]]
-    config_section_name: Optional[str]
-
-    def __init__(
-        self,
-        action_name: Optional[str] = None,
-        category: Optional["CoreCommand"] = None,
-        help: str = "",
-        description: str = "",
-        requires_api: bool = False,
-        base_parsers: Optional[List[_containers.BaseParser]] = None,
-        config_section_name: Optional[str] = None,
-    ):
-        """
-        Args:
-            action_name: The name of the action that the command will be
-                available under. Defaults to the name of the plugin class.
-            category: The category to place this command in. If not specified,
-                then the command will be top-level (i.e. uncategorized).
-            help: A help section for the command. This appears when listing the
-                help section of the command's category.
-            description: A help section for the command. This appears when
-                listing the help section for the command itself.
-            requires_api: If True, a platform API will be insantiated and
-                passed to the command function.
-            base_parsers: A list of base parsers to add to the command.
-            config_section_name: The name of the configuration section the
-                command should look for configurable options in. Defaults
-                to the name of the plugin the command is defined in.
-        """
-        object.__setattr__(self, "action_name", action_name)
-        object.__setattr__(self, "category", category)
-        object.__setattr__(self, "help", help)
-        object.__setattr__(self, "description", description)
-        object.__setattr__(self, "requires_api", requires_api)
-        object.__setattr__(self, "base_parsers", base_parsers)
-        object.__setattr__(self, "config_section_name", config_section_name)
+    if not actions:
+        raise ValueError(
+            f"argument 'actions' must be a non-empty list: {actions}"
+        )
+    return _CommandExtensionSettings(
+        actions=actions, config_section_name=config_section_name
+    )
+
+
+def option(
+    short_name: Optional[str] = None,
+    long_name: Optional[str] = None,
+    help: str = "",
+    required: bool = False,
+    default: Optional[Any] = None,
+    configurable: bool = False,
+    converter: Optional[Callable[[str], Any]] = None,
+    argparse_kwargs: Optional[Mapping[str, Any]] = None,
+):
+    """Create an option for a :py:class:`Command` or a
+    :py:class:`CommandExtension`.
 
+    Example usage:
 
-class CommandExtensionSettings(_containers.ImmutableMixin):
-    """Settings for a :py:class:`CommandExtension`."""
+    .. code-block:: python
+        :caption: ext.py
 
-    actions: List["Action"]
-    config_section_name: Optional[str]
+        import repobee_plug as plug
 
-    def __init__(
-        self,
-        actions: List["Action"],
-        config_section_name: Optional[str] = None,
-    ):
-        """
-        Args:
-            actions: A list of actions to extend.
-            config_section_name: Name of the configuration section that the
-                command extension will fetch configuration values from.
-                Defaults to the name of the plugin in which the extension is
-                defined.
-        """
-        if not actions:
-            raise ValueError(
-                f"argument 'actions' must be a non-empty list: {actions}"
-            )
-        object.__setattr__(self, "actions", actions)
-        object.__setattr__(self, "config_section_name", config_section_name)
 
+        class Hello(plug.Plugin, plug.cli.Command):
+            name = plug.cli.option(help="Your name.")
+            age = plug.cli.option(converter=int, help="Your age.")
 
-class MutuallyExclusiveGroup(_containers.ImmutableMixin):
-    """A group of mutually exclusive CLI options.
+            def command_callback(self, args, api):
+                print(
+                    f"Hello, my name is {args.name} "
+                    f"and I am {args.age} years old"
+                )
 
-    Attributes:
-        ALLOWED_TYPES: The types that are allowed to be passed in the
-            constructor kwargs.
-        options: The options that have been stored in this group.
-        required: Whether or not this mutex group is required.
+    This command can then be called like so:
+
+    .. code-block:: bash
+
+        $ repobee -p ext.py hello --name Alice --age 22
+        Hello, my name is Alice and I am 22 years old
+
+    Args:
+        short_name: The short name of this option. Must start with ``-``.
+        long_name: The long name of this option. Must start with `--`.
+        help: A description of this option that is used in the CLI help
+            section.
+        required: Whether or not this option is required.
+        default: A default value for this option.
+        configurable: Whether or not this option is configurable. If an option
+            is both configurable and required, having a value for the option
+            in the configuration file makes the option non-required.
+        converter: A converter function that takes a string and returns
+            the argument in its proper state. Should also perform input
+            validation and raise an error if the input is malformed.
+        argparse_kwargs: Keyword arguments that are passed directly to
+            :py:meth:`argparse.ArgumentParser.add_argument`
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line arguments.
     """
 
-    ALLOWED_TYPES = (Option,)
-    options: List[Tuple[str, Option]]
-    required: bool
+    return _Option(
+        short_name=short_name,
+        long_name=long_name,
+        configurable=configurable,
+        help=help,
+        converter=converter,
+        required=required,
+        default=default,
+        argument_type=ArgumentType.OPTION,
+        argparse_kwargs=argparse_kwargs or {},
+    )
+
+
+def positional(
+    help: str = "",
+    converter: Optional[Callable[[str], Any]] = None,
+    argparse_kwargs: Optional[Mapping[str, Any]] = None,
+) -> _Option:
+    """Create a positional argument for a :py:class:`Command` or a
+    :py:class:`CommandExtension`.
+
+    Example usage:
+
+    .. code-block:: python
+        :caption: ext.py
+
+        import repobee_plug as plug
 
-    def __init__(self, *, __required__: bool = False, **kwargs):
-        """
-        Args:
-            __required__: Whether or not this mutex group is required.
-            kwargs: Keyword arguments on the form ``name=plug.cli.Option()``.
-        """
 
-        def _check_type(key, value):
-            if not isinstance(value, self.ALLOWED_TYPES):
-                raise TypeError(
-                    f"{value.__class__.__name__} "
-                    f"not allowed in mutex group: {key}={value}"
+        class Hello(plug.Plugin, plug.cli.Command):
+            name = plug.cli.Positional(help="Your name.")
+            age = plug.cli.Positional(converter=int, help="Your age.")
+
+            def command_callback(self, args, api):
+                print(
+                    f"Hello, my name is {args.name} "
+                    f"and I am {args.age} years old"
                 )
-            return True
 
-        options = [
-            (key, value)
-            for key, value in kwargs.items()
-            if _check_type(key, value)
-        ]
+    This command can then be called like so:
 
-        object.__setattr__(self, "options", options)
-        object.__setattr__(self, "required", __required__)
+    .. code-block:: bash
+
+        $ repobee -p ext.py hello Alice 22
+        Hello, my name is Alice and I am 22 years old
+
+    Args:
+        help: The help section for the positional argument.
+        converter: A converter function that takes a string and returns
+            the argument in its proper state. Should also perform input
+            validation and raise an error if the input is malformed.
+        argparse_kwargs: Keyword arguments that are passed directly to
+            :py:meth:`argparse.ArgumentParser.add_argument`
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line argument.
+    """
+    return _Option(
+        help=help,
+        converter=converter,
+        argparse_kwargs=argparse_kwargs or {},
+        argument_type=ArgumentType.POSITIONAL,
+    )
+
+
+def mutually_exclusive_group(*, __required__: bool = False, **kwargs):
+    """
+    Args:
+        __required__: Whether or not this mutex group is required.
+        kwargs: Keyword arguments on the form ``name=plug.cli.option()``.
+    """
+    allowed_types = (ArgumentType.OPTION,)
+
+    def _check_arg_type(name, opt):
+        if opt.argument_type not in allowed_types:
+            raise ValueError(
+                f"{opt.argument_type.value} not allowed in mutex group"
+            )
+        return True
+
+    options = [
+        (key, value)
+        for key, value in kwargs.items()
+        if _check_arg_type(key, value)
+    ]
+    return _MutuallyExclusiveGroup(required=__required__, options=options)
+
+
+_MutuallyExclusiveGroup = collections.namedtuple(
+    "_MutuallyExclusiveGroup", ["options", "required"]
+)
 
 
 class CommandExtension:
@@ -220,10 +342,10 @@ class Command:
 
         class Greeting(plug.Plugin, plug.cli.Command):
 
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 short_name="-n", help="your name", required=True
             )
-            age = plug.cli.Option(
+            age = plug.cli.option(
                 converter=int, help="your age", default=30
             )
 


Test patch:
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 0d769a7..75f599d 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -120,8 +120,8 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(help="your name", required=True)
-            age = plug.cli.Option(converter=int, help="your age", default=30)
+            name = plug.cli.option(help="your name", required=True)
+            age = plug.cli.option(converter=int, help="your age", default=30)
 
             def command_callback(self, args, api):
                 print(f"My name is {args.name} and I am {args.age} years old")
@@ -157,7 +157,7 @@ class TestDeclarativeExtensionCommand:
         expected_requires_api = True
 
         class ExtCommand(plug.Plugin, plug.cli.Command):
-            __settings__ = plug.cli.CommandSettings(
+            __settings__ = plug.cli.command_settings(
                 category=expected_category,
                 action_name=expected_name,
                 help=expected_help,
@@ -194,7 +194,7 @@ class TestDeclarativeExtensionCommand:
         """Test configuring a default value for an option."""
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 help="Your name.", required=True, configurable=True
             )
 
@@ -218,7 +218,7 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(help="Your name.", required=True)
+            name = plug.cli.option(help="Your name.", required=True)
 
             def command_callback(self, args, api):
                 pass
@@ -246,7 +246,7 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Option(
+            name = plug.cli.option(
                 short_name="-n",
                 long_name="--your-name",
                 help="your name",
@@ -269,8 +269,8 @@ class TestDeclarativeExtensionCommand:
 
     def test_positional_arguments(self):
         class Greeting(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Positional()
-            age = plug.cli.Positional(converter=int)
+            name = plug.cli.positional()
+            age = plug.cli.positional(converter=int)
 
             def command_callback(self, args, api):
                 pass
@@ -292,10 +292,10 @@ class TestDeclarativeExtensionCommand:
         """
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            age_mutex = plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Option(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=1337),
+            age_mutex = plug.cli.mutually_exclusive_group(
+                age=plug.cli.option(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=1337)
                 ),
                 __required__=True,
             )
@@ -318,11 +318,11 @@ class TestDeclarativeExtensionCommand:
     def test_positionals_not_allowed_in_mutex_group(self):
         """Positional arguments don't make sense in a mutex group."""
 
-        with pytest.raises(TypeError) as exc_info:
-            plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Positional(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=1337),
+        with pytest.raises(ValueError) as exc_info:
+            plug.cli.mutually_exclusive_group(
+                age=plug.cli.positional(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=1337)
                 ),
                 __required__=True,
             )
@@ -334,10 +334,10 @@ class TestDeclarativeExtensionCommand:
         old = 1337
 
         class Greeting(plug.Plugin, plug.cli.Command):
-            age_mutex = plug.cli.MutuallyExclusiveGroup(
-                age=plug.cli.Option(converter=int),
-                old=plug.cli.Option(
-                    argparse_kwargs=dict(action="store_const", const=old),
+            age_mutex = plug.cli.mutually_exclusive_group(
+                age=plug.cli.option(converter=int),
+                old=plug.cli.option(
+                    argparse_kwargs=dict(action="store_const", const=old)
                 ),
                 __required__=True,
             )
@@ -369,11 +369,11 @@ class TestDeclarativeCommandExtension:
         """Tests adding a required option to ``config show``."""
 
         class ConfigShowExt(plug.Plugin, plug.cli.CommandExtension):
-            __settings__ = plug.cli.CommandExtensionSettings(
+            __settings__ = plug.cli.command_extension_settings(
                 actions=[plug.cli.CoreCommand.config.show]
             )
 
-            silly_new_option = plug.cli.Option(help="your name", required=True)
+            silly_new_option = plug.cli.option(help="your name", required=True)
 
         with pytest.raises(SystemExit):
             repobee.run(
@@ -423,7 +423,7 @@ class TestDeclarativeCommandExtension:
         with pytest.raises(ValueError) as exc_info:
 
             class Ext(plug.Plugin, plug.cli.CommandExtension):
-                __settings__ = plug.cli.CommandExtensionSettings(actions=[])
+                __settings__ = plug.cli.command_extension_settings(actions=[])
 
         assert "argument 'actions' must be a non-empty list" in str(
             exc_info.value