Skip to content

Commit 8d3de21

Browse files
committed
refactor: decouple CLI from custom method arguments
1 parent 09b3b22 commit 8d3de21

17 files changed

+153
-115
lines changed

gitlab/base.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020
import textwrap
2121
from dataclasses import dataclass
2222
from types import ModuleType
23-
from typing import Any, Dict, Iterable, Optional, Tuple, Type, Union
23+
from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Type, Union
2424

2525
import gitlab
2626
from gitlab import types as g_types
2727
from gitlab.exceptions import GitlabParsingError
28+
from gitlab.types import F
2829

2930
from .client import Gitlab, GitlabList
3031

@@ -336,6 +337,21 @@ class RequiredOptional:
336337
optional: Tuple[str, ...] = ()
337338

338339

340+
def custom_attrs(required: tuple = (), optional: tuple = ()) -> Callable[[F], F]:
341+
"""Decorates a custom method to add a RequiredOptional attribute.
342+
343+
Args:
344+
required: A tuple of API attributes required in the custom method
345+
optional: A tuple of API attributes optional in the custom method
346+
"""
347+
348+
def decorator(func: F) -> F:
349+
setattr(func, "_custom_attrs", RequiredOptional(required, optional))
350+
return func
351+
352+
return decorator
353+
354+
339355
class RESTManager:
340356
"""Base class for CRUD operations on objects.
341357

gitlab/cli.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@
2323
import re
2424
import sys
2525
from types import ModuleType
26-
from typing import Any, Callable, cast, Dict, Optional, Tuple, Type, TypeVar, Union
26+
from typing import Any, Callable, cast, Dict, Optional, Tuple, Type, Union
2727

2828
from requests.structures import CaseInsensitiveDict
2929

3030
import gitlab.config
31-
from gitlab.base import RESTObject
31+
from gitlab.base import RequiredOptional, RESTObject
32+
from gitlab.types import F
3233

3334
# This regex is based on:
3435
# https://github.com/jpvanhal/inflection/blob/master/inflection/__init__.py
@@ -43,24 +44,18 @@
4344
custom_actions: Dict[str, Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...], bool]]] = {}
4445

4546

46-
# For an explanation of how these type-hints work see:
47-
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
48-
#
49-
# The goal here is that functions which get decorated will retain their types.
50-
__F = TypeVar("__F", bound=Callable[..., Any])
51-
52-
5347
def register_custom_action(
5448
cls_names: Union[str, Tuple[str, ...]],
55-
mandatory: Tuple[str, ...] = (),
56-
optional: Tuple[str, ...] = (),
5749
custom_action: Optional[str] = None,
58-
) -> Callable[[__F], __F]:
59-
def wrap(f: __F) -> __F:
50+
) -> Callable[[F], F]:
51+
def wrap(f: F) -> F:
6052
@functools.wraps(f)
6153
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
6254
return f(*args, **kwargs)
6355

56+
action = custom_action or f.__name__.replace("_", "-")
57+
custom_attrs = getattr(f, "_custom_attrs", RequiredOptional())
58+
6459
# in_obj defines whether the method belongs to the obj or the manager
6560
in_obj = True
6661
if isinstance(cls_names, tuple):
@@ -76,10 +71,13 @@ def wrapped_f(*args: Any, **kwargs: Any) -> Any:
7671
if final_name not in custom_actions:
7772
custom_actions[final_name] = {}
7873

79-
action = custom_action or f.__name__.replace("_", "-")
80-
custom_actions[final_name][action] = (mandatory, optional, in_obj)
74+
custom_actions[final_name][action] = (
75+
custom_attrs.required,
76+
custom_attrs.optional,
77+
in_obj,
78+
)
8179

82-
return cast(__F, wrapped_f)
80+
return cast(F, wrapped_f)
8381

8482
return wrap
8583

gitlab/exceptions.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717

1818
import functools
19-
from typing import Any, Callable, cast, Optional, Type, TYPE_CHECKING, TypeVar, Union
19+
from typing import Any, Callable, cast, Optional, Type, TYPE_CHECKING, Union
20+
21+
from gitlab.types import F
2022

2123

2224
class GitlabError(Exception):
@@ -287,14 +289,7 @@ class GitlabUnfollowError(GitlabOperationError):
287289
pass
288290

289291

290-
# For an explanation of how these type-hints work see:
291-
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
292-
#
293-
# The goal here is that functions which get decorated will retain their types.
294-
__F = TypeVar("__F", bound=Callable[..., Any])
295-
296-
297-
def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
292+
def on_http_error(error: Type[Exception]) -> Callable[[F], F]:
298293
"""Manage GitlabHttpError exceptions.
299294
300295
This decorator function can be used to catch GitlabHttpError exceptions
@@ -304,14 +299,14 @@ def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
304299
The exception type to raise -- must inherit from GitlabError
305300
"""
306301

307-
def wrap(f: __F) -> __F:
302+
def wrap(f: F) -> F:
308303
@functools.wraps(f)
309304
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
310305
try:
311306
return f(*args, **kwargs)
312307
except GitlabHttpError as e:
313308
raise error(e.error_message, e.response_code, e.response_body) from e
314309

315-
return cast(__F, wrapped_f)
310+
return cast(F, wrapped_f)
316311

317312
return wrap

gitlab/mixins.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -578,9 +578,8 @@ class AccessRequestMixin(_RestObjectBase):
578578
_updated_attrs: Dict[str, Any]
579579
manager: base.RESTManager
580580

581-
@cli.register_custom_action(
582-
("ProjectAccessRequest", "GroupAccessRequest"), (), ("access_level",)
583-
)
581+
@cli.register_custom_action(("ProjectAccessRequest", "GroupAccessRequest"))
582+
@base.custom_attrs(optional=("access_level",))
584583
@exc.on_http_error(exc.GitlabUpdateError)
585584
def approve(
586585
self, access_level: int = gitlab.const.DEVELOPER_ACCESS, **kwargs: Any
@@ -752,7 +751,8 @@ def time_stats(self, **kwargs: Any) -> Dict[str, Any]:
752751
assert not isinstance(result, requests.Response)
753752
return result
754753

755-
@cli.register_custom_action(("ProjectIssue", "ProjectMergeRequest"), ("duration",))
754+
@cli.register_custom_action(("ProjectIssue", "ProjectMergeRequest"))
755+
@base.custom_attrs(required=("duration",))
756756
@exc.on_http_error(exc.GitlabTimeTrackingError)
757757
def time_estimate(self, duration: str, **kwargs: Any) -> Dict[str, Any]:
758758
"""Set an estimated time of work for the object.
@@ -790,7 +790,8 @@ def reset_time_estimate(self, **kwargs: Any) -> Dict[str, Any]:
790790
assert not isinstance(result, requests.Response)
791791
return result
792792

793-
@cli.register_custom_action(("ProjectIssue", "ProjectMergeRequest"), ("duration",))
793+
@cli.register_custom_action(("ProjectIssue", "ProjectMergeRequest"))
794+
@base.custom_attrs(required=("duration",))
794795
@exc.on_http_error(exc.GitlabTimeTrackingError)
795796
def add_spent_time(self, duration: str, **kwargs: Any) -> Dict[str, Any]:
796797
"""Add time spent working on the object.
@@ -866,9 +867,8 @@ def participants(self, **kwargs: Any) -> Dict[str, Any]:
866867

867868

868869
class BadgeRenderMixin(_RestManagerBase):
869-
@cli.register_custom_action(
870-
("GroupBadgeManager", "ProjectBadgeManager"), ("link_url", "image_url")
871-
)
870+
@cli.register_custom_action(("GroupBadgeManager", "ProjectBadgeManager"))
871+
@base.custom_attrs(required=("link_url", "image_url"))
872872
@exc.on_http_error(exc.GitlabRenderError)
873873
def render(self, link_url: str, image_url: str, **kwargs: Any) -> Dict[str, Any]:
874874
"""Preview link_url and image_url after interpolation.

gitlab/types.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
# You should have received a copy of the GNU Lesser General Public License
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717

18-
from typing import Any, Optional, TYPE_CHECKING
18+
from typing import Any, Callable, Optional, TYPE_CHECKING, TypeVar
19+
20+
# TypeVar for decorators so that decorated functions retain their signatures.
21+
# See https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
22+
F = TypeVar("F", bound=Callable[..., Any])
1923

2024

2125
class GitlabAttribute:

gitlab/v4/objects/artifacts.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from gitlab import cli
1010
from gitlab import exceptions as exc
1111
from gitlab import utils
12-
from gitlab.base import RESTManager, RESTObject
12+
from gitlab.base import custom_attrs, RESTManager, RESTObject
1313

1414
__all__ = ["ProjectArtifact", "ProjectArtifactManager"]
1515

@@ -25,9 +25,8 @@ class ProjectArtifactManager(RESTManager):
2525
_path = "/projects/{project_id}/jobs/artifacts"
2626
_from_parent_attrs = {"project_id": "id"}
2727

28-
@cli.register_custom_action(
29-
"Project", ("ref_name", "job"), ("job_token",), custom_action="artifacts"
30-
)
28+
@cli.register_custom_action("Project", custom_action="artifacts")
29+
@custom_attrs(required=("ref_name", "job"), optional=("job_token",))
3130
def __call__(
3231
self,
3332
*args: Any,
@@ -62,9 +61,8 @@ def delete(self, **kwargs: Any) -> None:
6261
assert path is not None
6362
self.gitlab.http_delete(path, **kwargs)
6463

65-
@cli.register_custom_action(
66-
"ProjectArtifactManager", ("ref_name", "job"), ("job_token",)
67-
)
64+
@cli.register_custom_action("ProjectArtifactManager")
65+
@custom_attrs(required=("ref_name", "job"), optional=("job_token",))
6866
@exc.on_http_error(exc.GitlabGetError)
6967
def download(
7068
self,
@@ -105,9 +103,8 @@ def download(
105103
assert isinstance(result, requests.Response)
106104
return utils.response_content(result, streamed, action, chunk_size)
107105

108-
@cli.register_custom_action(
109-
"ProjectArtifactManager", ("ref_name", "artifact_path", "job")
110-
)
106+
@cli.register_custom_action("ProjectArtifactManager")
107+
@custom_attrs(required=("ref_name", "artifact_path", "job"))
111108
@exc.on_http_error(exc.GitlabGetError)
112109
def raw(
113110
self,

gitlab/v4/objects/commits.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import requests
44

55
import gitlab
6-
from gitlab import cli
6+
from gitlab import base, cli
77
from gitlab import exceptions as exc
88
from gitlab.base import RequiredOptional, RESTManager, RESTObject
99
from gitlab.mixins import CreateMixin, ListMixin, RefreshMixin, RetrieveMixin
@@ -45,7 +45,8 @@ def diff(self, **kwargs: Any) -> Union[gitlab.GitlabList, List[Dict[str, Any]]]:
4545
path = f"{self.manager.path}/{self.encoded_id}/diff"
4646
return self.manager.gitlab.http_list(path, **kwargs)
4747

48-
@cli.register_custom_action("ProjectCommit", ("branch",))
48+
@cli.register_custom_action("ProjectCommit")
49+
@base.custom_attrs(required=("branch",))
4950
@exc.on_http_error(exc.GitlabCherryPickError)
5051
def cherry_pick(self, branch: str, **kwargs: Any) -> None:
5152
"""Cherry-pick a commit into a branch.
@@ -62,7 +63,8 @@ def cherry_pick(self, branch: str, **kwargs: Any) -> None:
6263
post_data = {"branch": branch}
6364
self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
6465

65-
@cli.register_custom_action("ProjectCommit", optional=("type",))
66+
@cli.register_custom_action("ProjectCommit")
67+
@base.custom_attrs(optional=("type",))
6668
@exc.on_http_error(exc.GitlabGetError)
6769
def refs(
6870
self, type: str = "all", **kwargs: Any
@@ -104,7 +106,8 @@ def merge_requests(
104106
path = f"{self.manager.path}/{self.encoded_id}/merge_requests"
105107
return self.manager.gitlab.http_list(path, **kwargs)
106108

107-
@cli.register_custom_action("ProjectCommit", ("branch",))
109+
@cli.register_custom_action("ProjectCommit")
110+
@base.custom_attrs(required=("branch",))
108111
@exc.on_http_error(exc.GitlabRevertError)
109112
def revert(
110113
self, branch: str, **kwargs: Any

gitlab/v4/objects/container_registry.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, cast, TYPE_CHECKING, Union
22

3-
from gitlab import cli
3+
from gitlab import base, cli
44
from gitlab import exceptions as exc
55
from gitlab.base import RESTManager, RESTObject
66
from gitlab.mixins import DeleteMixin, ListMixin, ObjectDeleteMixin, RetrieveMixin
@@ -32,9 +32,9 @@ class ProjectRegistryTagManager(DeleteMixin, RetrieveMixin, RESTManager):
3232
_from_parent_attrs = {"project_id": "project_id", "repository_id": "id"}
3333
_path = "/projects/{project_id}/registry/repositories/{repository_id}/tags"
3434

35-
@cli.register_custom_action(
36-
"ProjectRegistryTagManager",
37-
("name_regex_delete",),
35+
@cli.register_custom_action("ProjectRegistryTagManager")
36+
@base.custom_attrs(
37+
required=("name_regex_delete",),
3838
optional=("keep_n", "name_regex_keep", "older_than"),
3939
)
4040
@exc.on_http_error(exc.GitlabDeleteError)

gitlab/v4/objects/deploy_keys.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import requests
44

5-
from gitlab import cli
5+
from gitlab import base, cli
66
from gitlab import exceptions as exc
77
from gitlab.base import RequiredOptional, RESTManager, RESTObject
88
from gitlab.mixins import CRUDMixin, ListMixin, ObjectDeleteMixin, SaveMixin
@@ -35,7 +35,8 @@ class ProjectKeyManager(CRUDMixin, RESTManager):
3535
_create_attrs = RequiredOptional(required=("title", "key"), optional=("can_push",))
3636
_update_attrs = RequiredOptional(optional=("title", "can_push"))
3737

38-
@cli.register_custom_action("ProjectKeyManager", ("key_id",))
38+
@cli.register_custom_action("ProjectKeyManager")
39+
@base.custom_attrs(required=("key_id",))
3940
@exc.on_http_error(exc.GitlabProjectDeployKeyError)
4041
def enable(
4142
self, key_id: int, **kwargs: Any

gitlab/v4/objects/files.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import requests
55

6-
from gitlab import cli
6+
from gitlab import base, cli
77
from gitlab import exceptions as exc
88
from gitlab import utils
99
from gitlab.base import RequiredOptional, RESTManager, RESTObject
@@ -95,7 +95,8 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTMa
9595
optional=("encoding", "author_email", "author_name"),
9696
)
9797

98-
@cli.register_custom_action("ProjectFileManager", ("file_path", "ref"))
98+
@cli.register_custom_action("ProjectFileManager")
99+
@base.custom_attrs(required=("file_path", "ref"))
99100
# NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore
100101
# type error
101102
def get( # type: ignore
@@ -117,10 +118,10 @@ def get( # type: ignore
117118
"""
118119
return cast(ProjectFile, GetMixin.get(self, file_path, ref=ref, **kwargs))
119120

120-
@cli.register_custom_action(
121-
"ProjectFileManager",
122-
("file_path", "branch", "content", "commit_message"),
123-
("encoding", "author_email", "author_name"),
121+
@cli.register_custom_action("ProjectFileManager")
122+
@base.custom_attrs(
123+
required=("file_path", "branch", "content", "commit_message"),
124+
optional=("encoding", "author_email", "author_name"),
124125
)
125126
@exc.on_http_error(exc.GitlabCreateError)
126127
def create(
@@ -184,9 +185,8 @@ def update( # type: ignore
184185
assert isinstance(result, dict)
185186
return result
186187

187-
@cli.register_custom_action(
188-
"ProjectFileManager", ("file_path", "branch", "commit_message")
189-
)
188+
@cli.register_custom_action("ProjectFileManager")
189+
@base.custom_attrs(required=("file_path", "branch", "commit_message"))
190190
@exc.on_http_error(exc.GitlabDeleteError)
191191
# NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore
192192
# type error
@@ -210,7 +210,8 @@ def delete( # type: ignore
210210
data = {"branch": branch, "commit_message": commit_message}
211211
self.gitlab.http_delete(path, query_data=data, **kwargs)
212212

213-
@cli.register_custom_action("ProjectFileManager", ("file_path", "ref"))
213+
@cli.register_custom_action("ProjectFileManager")
214+
@base.custom_attrs(required=("file_path", "ref"))
214215
@exc.on_http_error(exc.GitlabGetError)
215216
def raw(
216217
self,
@@ -251,7 +252,8 @@ def raw(
251252
assert isinstance(result, requests.Response)
252253
return utils.response_content(result, streamed, action, chunk_size)
253254

254-
@cli.register_custom_action("ProjectFileManager", ("file_path", "ref"))
255+
@cli.register_custom_action("ProjectFileManager")
256+
@base.custom_attrs(required=("file_path", "ref"))
255257
@exc.on_http_error(exc.GitlabListError)
256258
def blame(self, file_path: str, ref: str, **kwargs: Any) -> List[Dict[str, Any]]:
257259
"""Return the content of a file for a commit.

0 commit comments

Comments
 (0)