Repository: python-gitlab/python-gitlab

Issue:
Exclusive arguments not allowed in CLI
In [gitlab/v4/cli.py](https://github.com/python-gitlab/python-gitlab/blob/72e1aa7f2c2aad739db2f5dd4394165a5b5f2432/gitlab/v4/cli.py#L256-L264), required and optional arguments are added to the argument parser:

```
if action_name == "create":
    for x in mgr_cls._create_attrs.required:
        sub_parser_action.add_argument(
            f"--{x.replace('_', '-')}", required=True
        )
    for x in mgr_cls._create_attrs.optional:
        sub_parser_action.add_argument(
            f"--{x.replace('_', '-')}", required=False
        )
```

Besides *required* and *optional*, the RequiredOptional class can also have an *exclusive* field, and many objects use this to list options not in *required* or *optional*. From [gitlab/v4/objects/invitations.py](https://github.com/python-gitlab/python-gitlab/blob/72e1aa7f2c2aad739db2f5dd4394165a5b5f2432/gitlab/v4/objects/invitations.py#L34-L43):

```
class ProjectInvitationManager(InvitationMixin, RESTManager):
    _path = "/projects/{project_id}/invitations"
    _obj_cls = ProjectInvitation
    _from_parent_attrs = {"project_id": "id"}
    _create_attrs = RequiredOptional(
        required=("access_level",),
        optional=(
            "expires_at",
            "invite_source",
            "tasks_to_be_done",
            "tasks_project_id",
        ),
        exclusive=("email", "user_id"),
    )
```

Here *email* is an argument defined in *exclusive*, and not allowed on the CLI:

```
$ python -m gitlab --server-url http://172.16.38.146/ project-invitation create --access-level 10 --email myemail --project-id 1
...
__main__.py: error: unrecognized arguments: --email myemail
```

Issue #2738 describes a similar problem.


Discussion:

Resolving patch:
diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py
--- a/gitlab/v4/cli.py
+++ b/gitlab/v4/cli.py
@@ -262,6 +262,10 @@ def _populate_sub_parser_by_class(
                 sub_parser_action.add_argument(
                     f"--{x.replace('_', '-')}", required=False
                 )
+            if mgr_cls._create_attrs.exclusive:
+                group = sub_parser_action.add_mutually_exclusive_group()
+                for x in mgr_cls._create_attrs.exclusive:
+                    group.add_argument(f"--{x.replace('_', '-')}")
 
         if action_name == "update":
             if cls._id_attr is not None:
@@ -280,6 +284,11 @@ def _populate_sub_parser_by_class(
                         f"--{x.replace('_', '-')}", required=False
                     )
 
+            if mgr_cls._update_attrs.exclusive:
+                group = sub_parser_action.add_mutually_exclusive_group()
+                for x in mgr_cls._update_attrs.exclusive:
+                    group.add_argument(f"--{x.replace('_', '-')}")
+
     if cls.__name__ in cli.custom_actions:
         name = cls.__name__
         for action_name in cli.custom_actions[name]:


Test patch:
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -11,6 +11,8 @@
 import gitlab.base
 from gitlab import cli
 from gitlab.exceptions import GitlabError
+from gitlab.mixins import CreateMixin, UpdateMixin
+from gitlab.types import RequiredOptional
 from gitlab.v4 import cli as v4_cli
 
 
@@ -157,6 +159,65 @@ def test_v4_parser():
     assert actions["--name"].required
 
 
+def test_extend_parser():
+    class ExceptionArgParser(argparse.ArgumentParser):
+        def error(self, message):
+            "Raise error instead of exiting on invalid arguments, to make testing easier"
+            raise ValueError(message)
+
+    class Fake:
+        _id_attr = None
+
+    class FakeManager(gitlab.base.RESTManager, CreateMixin, UpdateMixin):
+        _obj_cls = Fake
+        _create_attrs = RequiredOptional(
+            required=("create",),
+            optional=("opt_create",),
+            exclusive=("create_a", "create_b"),
+        )
+        _update_attrs = RequiredOptional(
+            required=("update",),
+            optional=("opt_update",),
+            exclusive=("update_a", "update_b"),
+        )
+
+    parser = ExceptionArgParser()
+    with mock.patch.dict(
+        "gitlab.v4.objects.__dict__", {"FakeManager": FakeManager}, clear=True
+    ):
+        v4_cli.extend_parser(parser)
+
+    assert parser.parse_args(["fake", "create", "--create", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--opt-create", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--create-a", "1"])
+    assert parser.parse_args(["fake", "create", "--create", "1", "--create-b", "1"])
+
+    with pytest.raises(ValueError):
+        # missing required "create"
+        parser.parse_args(["fake", "create", "--opt_create", "1"])
+
+    with pytest.raises(ValueError):
+        # both exclusive options
+        parser.parse_args(
+            ["fake", "create", "--create", "1", "--create-a", "1", "--create-b", "1"]
+        )
+
+    assert parser.parse_args(["fake", "update", "--update", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--opt-update", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--update-a", "1"])
+    assert parser.parse_args(["fake", "update", "--update", "1", "--update-b", "1"])
+
+    with pytest.raises(ValueError):
+        # missing required "update"
+        parser.parse_args(["fake", "update", "--opt_update", "1"])
+
+    with pytest.raises(ValueError):
+        # both exclusive options
+        parser.parse_args(
+            ["fake", "update", "--update", "1", "--update-a", "1", "--update-b", "1"]
+        )
+
+
 @pytest.mark.skipif(sys.version_info < (3, 8), reason="added in 3.8")
 def test_legacy_display_without_fields_warns(fake_object_no_id):
     printer = v4_cli.LegacyPrinter()