Repository: repobee/repobee

Issue:
Add a flag function to create a CLI flag
Command line flags are very common, so creating a convenience function to create one would be nice. It could possibly also be double as a `store_const` wrapper.

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

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

```diff
@@             Coverage Diff             @@
##           master     #533       +/-   ##
===========================================
+ Coverage   60.93%   88.18%   +27.24%     
===========================================
  Files          30       47       +17     
  Lines        1938     2463      +525     
  Branches      350      405       +55     
===========================================
+ Hits         1181     2172      +991     
+ Misses        690      241      -449     
+ Partials       67       50       -17     
```


| [Impacted Files](https://codecov.io/gh/repobee/repobee/pull/533?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/repobee\_plug/cli/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvX19pbml0X18ucHk=) | `100.00% <ø> (ø)` | |
| [src/repobee\_plug/\_pluginmeta.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fcGx1Z2lubWV0YS5weQ==) | `97.93% <100.00%> (ø)` | |
| [src/repobee\_plug/cli/args.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvYXJncy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/\_repobee/ext/gitlab.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL19yZXBvYmVlL2V4dC9naXRsYWIucHk=) | `63.86% <0.00%> (-15.33%)` | :arrow_down: |
| [src/repobee\_plug/\_\_init\_\_.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX2luaXRfXy5weQ==) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_name.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fbmFtZS5weQ==) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/cli/commandmarkers.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9jbGkvY29tbWFuZG1hcmtlcnMucHk=) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_serialize.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fc2VyaWFsaXplLnB5) | `100.00% <0.00%> (ø)` | |
| [src/repobee\_plug/\_\_version.py](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree#diff-c3JjL3JlcG9iZWVfcGx1Zy9fX3ZlcnNpb24ucHk=) | `100.00% <0.00%> (ø)` | |
| ... and [34 more](https://codecov.io/gh/repobee/repobee/pull/533/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/repobee/repobee/pull/533?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/533?src=pr&el=footer). Last update [b07085b...d062a41](https://codecov.io/gh/repobee/repobee/pull/533?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 94f4cd5..2b40c4f 100644
--- a/src/repobee_plug/_pluginmeta.py
+++ b/src/repobee_plug/_pluginmeta.py
@@ -198,7 +198,7 @@ def _add_option(
         else opt.help or ""
     )
 
-    if opt.argument_type == cli.ArgumentType.OPTION:
+    if opt.argument_type in [cli.ArgumentType.OPTION, cli.ArgumentType.FLAG]:
         if opt.short_name:
             args.append(opt.short_name)
 
@@ -208,8 +208,9 @@ def _add_option(
             args.append(f"--{name.replace('_', '-')}")
 
         kwargs["dest"] = name
-        # configured value takes precedence over default
-        kwargs["default"] = configured_value or opt.default
+        if not opt.argument_type == cli.ArgumentType.FLAG:
+            # 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 opt.argument_type == cli.ArgumentType.POSITIONAL:
diff --git a/src/repobee_plug/cli/__init__.py b/src/repobee_plug/cli/__init__.py
index 9ed56eb..8887554 100644
--- a/src/repobee_plug/cli/__init__.py
+++ b/src/repobee_plug/cli/__init__.py
@@ -3,6 +3,7 @@ from .categorization import category
 from .args import (
     option,
     positional,
+    flag,
     mutually_exclusive_group,
     ArgumentType,
     is_cli_arg,
@@ -17,6 +18,7 @@ __all__ = [
     "ArgumentType",
     "option",
     "positional",
+    "flag",
     "mutually_exclusive_group",
     "category",
     "command_settings",
diff --git a/src/repobee_plug/cli/args.py b/src/repobee_plug/cli/args.py
index e44431a..6c9d344 100644
--- a/src/repobee_plug/cli/args.py
+++ b/src/repobee_plug/cli/args.py
@@ -5,9 +5,10 @@ from typing import Optional, Any, Callable, Mapping
 
 
 class ArgumentType(enum.Enum):
-    OPTION = "Option"
-    POSITIONAL = "Positional"
-    MUTEX_GROUP = "Mutex"
+    OPTION = "option"
+    POSITIONAL = "positional"
+    MUTEX_GROUP = "mutually_exclusive_group"
+    FLAG = "flag"
 
 
 Option = collections.namedtuple(
@@ -130,8 +131,8 @@ def positional(
 
 
         class Hello(plug.Plugin, plug.cli.Command):
-            name = plug.cli.Positional(help="Your name.")
-            age = plug.cli.Positional(converter=int, help="Your age.")
+            name = plug.cli.positional(help="Your name.")
+            age = plug.cli.positional(converter=int, help="Your age.")
 
             def command(self, api):
                 print(
@@ -165,13 +166,92 @@ def positional(
     )
 
 
+def flag(
+    short_name: Optional[str] = None,
+    long_name: Optional[str] = None,
+    help: str = "",
+    const: Any = True,
+    default: Optional[Any] = None,
+) -> Option:
+    """Create a command line flag for a :py:class:`Command` or a
+    :py:class`CommandExtension`. This is simply a convenience wrapper around
+    :py:func:`option`.
+
+    A flag is specified on the command line as ``--flag``, and causes a
+    constant to be stored. If the flag is omitted, a default value is used
+    instead. The default behavior is that specifying ``--flag``
+    stores the constant ``True``, and omitting it causes it to default to
+    ``False``. It can also be used to store any other form of constant by
+    specifying the ``const`` argument. If so, then omitting the flag will cause
+    it to default to ``None`` instead of ``False``. Finally, the default value
+    can also be overridden by specifying the ``default`` argument.
+
+    Example:
+
+    .. code-block:: python
+        :caption: ext.py
+
+        import repobee_plug as plug
+
+
+        class Flags(plug.Plugin, plug.cli.Command):
+            # a normal flag, which toggles between True and False
+            is_great = plug.cli.flag()
+            # a "reverse" flag which defaults to True instead of False
+            not_great = plug.cli.flag(const=False, default=True)
+            # a flag that stores a constant and defaults to None
+            meaning = plug.cli.flag(const=42)
+            # a flag that stores a constant and defaults to another constant
+            approve = plug.cli.flag(const="yes", default="no")
+
+            def command(self, api):
+                print("is_great", self.is_great)
+                print("not_great", self.not_great)
+                print("meaning", self.meaning)
+                print("approve", self.approve)
+
+    We can then call this command (for example) like so:
+
+        .. code-block:: bash
+
+            $ repobee -p ext.py flags --meaning --not-great
+            is_great False
+            not_great False
+            meaning 42
+            approve no
+
+    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.
+        const: The constant to store.
+        default: The value to default to if the flag is omitted.
+    Returns:
+        A CLI argument wrapper used internally by RepoBee to create command
+        line argument.
+    """
+    resolved_default = (
+        not const if default is None and isinstance(const, bool) else default
+    )
+    return Option(
+        short_name=short_name,
+        long_name=long_name,
+        help=help,
+        argparse_kwargs=dict(
+            action="store_const", const=const, default=resolved_default
+        ),
+        argument_type=ArgumentType.FLAG,
+    )
+
+
 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,)
+    allowed_types = (ArgumentType.OPTION, ArgumentType.FLAG)
 
     def _check_arg_type(name, opt):
         if opt.argument_type not in allowed_types:


Test patch:
diff --git a/tests/unit_tests/repobee_plug/test_pluginmeta.py b/tests/unit_tests/repobee_plug/test_pluginmeta.py
index 37461d4..2e09683 100644
--- a/tests/unit_tests/repobee_plug/test_pluginmeta.py
+++ b/tests/unit_tests/repobee_plug/test_pluginmeta.py
@@ -256,9 +256,7 @@ class TestDeclarativeExtensionCommand:
         class Greeting(plug.Plugin, plug.cli.Command):
             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)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
@@ -285,13 +283,14 @@ class TestDeclarativeExtensionCommand:
         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)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
-        assert "Positional not allowed in mutex group" in str(exc_info.value)
+        assert (
+            f"{plug.cli.ArgumentType.POSITIONAL.value} not allowed in mutex"
+            in str(exc_info.value)
+        )
 
     def test_mutex_group_allows_one_argument(self):
         """Test that a mutex group allows one argument to be specified."""
@@ -300,9 +299,7 @@ class TestDeclarativeExtensionCommand:
         class Greeting(plug.Plugin, plug.cli.Command):
             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)
-                ),
+                old=plug.cli.flag(const=1337),
                 __required__=True,
             )
 
@@ -415,11 +412,7 @@ class TestDeclarativeExtensionCommand:
             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,
+                high=plug.cli.flag(), low=plug.cli.flag(), __required__=True,
             )
 
             def command(self, api):