Skip to content

Commit 6aef2da

Browse files
chore: mypy: Disallow untyped definitions
Be more strict and don't allow untyped definitions on the files we check. Also this adds type-hints for two of the decorators so that now functions/methods decorated by them will have their types be revealed correctly.
1 parent a6b6cd4 commit 6aef2da

File tree

8 files changed

+78
-53
lines changed

8 files changed

+78
-53
lines changed

.mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ files = gitlab/*.py
44
# disallow_incomplete_defs: This flag reports an error whenever it encounters a
55
# partly annotated function definition.
66
disallow_incomplete_defs = True
7+
# disallow_untyped_defs: This flag reports an error whenever it encounters a
8+
# function without type annotations or with incomplete type annotations.
9+
disallow_untyped_defs = True

gitlab/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import gitlab.cli
22

33

4-
__name__ == "__main__" and gitlab.cli.main()
4+
if __name__ == "__main__":
5+
gitlab.cli.main()

gitlab/base.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import importlib
1919
from types import ModuleType
20-
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type
20+
from typing import Any, Dict, Iterable, NamedTuple, Optional, Tuple, Type
2121

2222
from .client import Gitlab, GitlabList
2323
from gitlab import types as g_types
@@ -133,8 +133,8 @@ def __ne__(self, other: object) -> bool:
133133
return self.get_id() != other.get_id()
134134
return super(RESTObject, self) != other
135135

136-
def __dir__(self):
137-
return super(RESTObject, self).__dir__() | self.attributes.keys()
136+
def __dir__(self) -> Iterable[str]:
137+
return set(self.attributes).union(super(RESTObject, self).__dir__())
138138

139139
def __hash__(self) -> int:
140140
if not self.get_id():
@@ -155,7 +155,7 @@ def _update_attrs(self, new_attrs: Dict[str, Any]) -> None:
155155
self.__dict__["_updated_attrs"] = {}
156156
self.__dict__["_attrs"] = new_attrs
157157

158-
def get_id(self):
158+
def get_id(self) -> Any:
159159
"""Returns the id of the resource."""
160160
if self._id_attr is None or not hasattr(self, self._id_attr):
161161
return None
@@ -207,10 +207,10 @@ def __iter__(self) -> "RESTObjectList":
207207
def __len__(self) -> int:
208208
return len(self._list)
209209

210-
def __next__(self):
210+
def __next__(self) -> RESTObject:
211211
return self.next()
212212

213-
def next(self):
213+
def next(self) -> RESTObject:
214214
data = self._list.next()
215215
return self._obj_cls(self.manager, data)
216216

gitlab/cli.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import functools
2222
import re
2323
import sys
24-
from typing import Any, Callable, Dict, Optional, Tuple, Union
24+
from typing import Any, Callable, cast, Dict, Optional, Tuple, TypeVar, Union
2525

2626
import gitlab.config # noqa: F401
2727

@@ -35,14 +35,21 @@
3535
custom_actions: Dict[str, Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...], bool]]] = {}
3636

3737

38+
# For an explanation of how these type-hints work see:
39+
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
40+
#
41+
# The goal here is that functions which get decorated will retain their types.
42+
__F = TypeVar("__F", bound=Callable[..., Any])
43+
44+
3845
def register_custom_action(
3946
cls_names: Union[str, Tuple[str, ...]],
4047
mandatory: Tuple[str, ...] = tuple(),
4148
optional: Tuple[str, ...] = tuple(),
42-
) -> Callable:
43-
def wrap(f: Callable) -> Callable:
49+
) -> Callable[[__F], __F]:
50+
def wrap(f: __F) -> __F:
4451
@functools.wraps(f)
45-
def wrapped_f(*args, **kwargs):
52+
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
4653
return f(*args, **kwargs)
4754

4855
# in_obj defines whether the method belongs to the obj or the manager
@@ -63,7 +70,7 @@ def wrapped_f(*args, **kwargs):
6370
action = f.__name__.replace("_", "-")
6471
custom_actions[final_name][action] = (mandatory, optional, in_obj)
6572

66-
return wrapped_f
73+
return cast(__F, wrapped_f)
6774

6875
return wrap
6976

@@ -135,12 +142,16 @@ def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser:
135142
return parser
136143

137144

138-
def _get_parser(cli_module):
145+
def _get_parser() -> argparse.ArgumentParser:
146+
# NOTE: We must delay import of gitlab.v4.cli until now or
147+
# otherwise it will cause circular import errors
148+
import gitlab.v4.cli
149+
139150
parser = _get_base_parser()
140-
return cli_module.extend_parser(parser)
151+
return gitlab.v4.cli.extend_parser(parser)
141152

142153

143-
def _parse_value(v):
154+
def _parse_value(v: Any) -> Any:
144155
if isinstance(v, str) and v.startswith("@"):
145156
# If the user-provided value starts with @, we try to read the file
146157
# path provided after @ as the real value. Exit on any error.
@@ -162,18 +173,10 @@ def docs() -> argparse.ArgumentParser:
162173
if "sphinx" not in sys.modules:
163174
sys.exit("Docs parser is only intended for build_sphinx")
164175

165-
# NOTE: We must delay import of gitlab.v4.cli until now or
166-
# otherwise it will cause circular import errors
167-
import gitlab.v4.cli
168-
169-
return _get_parser(gitlab.v4.cli)
170-
176+
return _get_parser()
171177

172-
def main():
173-
# NOTE: We must delay import of gitlab.v4.cli until now or
174-
# otherwise it will cause circular import errors
175-
import gitlab.v4.cli
176178

179+
def main() -> None:
177180
if "--version" in sys.argv:
178181
print(gitlab.__version__)
179182
sys.exit(0)
@@ -183,7 +186,7 @@ def main():
183186
# This first parsing step is used to find the gitlab config to use, and
184187
# load the propermodule (v3 or v4) accordingly. At that point we don't have
185188
# any subparser setup
186-
(options, args) = parser.parse_known_args(sys.argv)
189+
(options, _) = parser.parse_known_args(sys.argv)
187190
try:
188191
config = gitlab.config.GitlabConfigParser(options.gitlab, options.config_file)
189192
except gitlab.config.ConfigError as e:
@@ -196,14 +199,14 @@ def main():
196199
raise ModuleNotFoundError(name="gitlab.v%s.cli" % config.api_version)
197200

198201
# Now we build the entire set of subcommands and do the complete parsing
199-
parser = _get_parser(gitlab.v4.cli)
202+
parser = _get_parser()
200203
try:
201204
import argcomplete # type: ignore
202205

203206
argcomplete.autocomplete(parser)
204207
except Exception:
205208
pass
206-
args = parser.parse_args(sys.argv[1:])
209+
args = parser.parse_args()
207210

208211
config_files = args.config_file
209212
gitlab_id = args.gitlab
@@ -216,7 +219,7 @@ def main():
216219
action = args.whaction
217220
what = args.what
218221

219-
args = args.__dict__
222+
args_dict = vars(args)
220223
# Remove CLI behavior-related args
221224
for item in (
222225
"gitlab",
@@ -228,8 +231,8 @@ def main():
228231
"version",
229232
"output",
230233
):
231-
args.pop(item)
232-
args = {k: _parse_value(v) for k, v in args.items() if v is not None}
234+
args_dict.pop(item)
235+
args_dict = {k: _parse_value(v) for k, v in args_dict.items() if v is not None}
233236

234237
try:
235238
gl = gitlab.Gitlab.from_config(gitlab_id, config_files)
@@ -241,6 +244,4 @@ def main():
241244
if debug:
242245
gl.enable_debug()
243246

244-
gitlab.v4.cli.run(gl, what, action, args, verbose, output, fields)
245-
246-
sys.exit(0)
247+
gitlab.v4.cli.run(gl, what, action, args_dict, verbose, output, fields)

gitlab/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def __init__(
206206
except Exception:
207207
pass
208208

209-
def _get_values_from_helper(self):
209+
def _get_values_from_helper(self) -> None:
210210
"""Update attributes that may get values from an external helper program"""
211211
for attr in HELPER_ATTRIBUTES:
212212
value = getattr(self, attr)

gitlab/exceptions.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,16 @@
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, TypeVar, TYPE_CHECKING, Union
1920

2021

2122
class GitlabError(Exception):
22-
def __init__(self, error_message="", response_code=None, response_body=None):
23+
def __init__(
24+
self,
25+
error_message: Union[str, bytes] = "",
26+
response_code: Optional[int] = None,
27+
response_body: Optional[bytes] = None,
28+
) -> None:
2329

2430
Exception.__init__(self, error_message)
2531
# Http status code
@@ -30,11 +36,15 @@ def __init__(self, error_message="", response_code=None, response_body=None):
3036
try:
3137
# if we receive str/bytes we try to convert to unicode/str to have
3238
# consistent message types (see #616)
39+
if TYPE_CHECKING:
40+
assert isinstance(error_message, bytes)
3341
self.error_message = error_message.decode()
3442
except Exception:
43+
if TYPE_CHECKING:
44+
assert isinstance(error_message, str)
3545
self.error_message = error_message
3646

37-
def __str__(self):
47+
def __str__(self) -> str:
3848
if self.response_code is not None:
3949
return "{0}: {1}".format(self.response_code, self.error_message)
4050
else:
@@ -269,7 +279,14 @@ class GitlabUnfollowError(GitlabOperationError):
269279
pass
270280

271281

272-
def on_http_error(error):
282+
# For an explanation of how these type-hints work see:
283+
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
284+
#
285+
# The goal here is that functions which get decorated will retain their types.
286+
__F = TypeVar("__F", bound=Callable[..., Any])
287+
288+
289+
def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
273290
"""Manage GitlabHttpError exceptions.
274291
275292
This decorator function can be used to catch GitlabHttpError exceptions
@@ -280,14 +297,14 @@ def on_http_error(error):
280297
GitlabError
281298
"""
282299

283-
def wrap(f):
300+
def wrap(f: __F) -> __F:
284301
@functools.wraps(f)
285-
def wrapped_f(*args, **kwargs):
302+
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
286303
try:
287304
return f(*args, **kwargs)
288305
except GitlabHttpError as e:
289306
raise error(e.error_message, e.response_code, e.response_body) from e
290307

291-
return wrapped_f
308+
return cast(__F, wrapped_f)
292309

293310
return wrap

gitlab/tests/test_cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import pytest
2727

2828
from gitlab import cli
29-
import gitlab.v4.cli
3029

3130

3231
def test_what_to_cls():
@@ -94,14 +93,14 @@ def test_base_parser():
9493

9594

9695
def test_v4_parse_args():
97-
parser = cli._get_parser(gitlab.v4.cli)
96+
parser = cli._get_parser()
9897
args = parser.parse_args(["project", "list"])
9998
assert args.what == "project"
10099
assert args.whaction == "list"
101100

102101

103102
def test_v4_parser():
104-
parser = cli._get_parser(gitlab.v4.cli)
103+
parser = cli._get_parser()
105104
subparsers = next(
106105
action
107106
for action in parser._actions

gitlab/types.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,46 +15,50 @@
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
19+
1820

1921
class GitlabAttribute(object):
20-
def __init__(self, value=None):
22+
def __init__(self, value: Any = None) -> None:
2123
self._value = value
2224

23-
def get(self):
25+
def get(self) -> Any:
2426
return self._value
2527

26-
def set_from_cli(self, cli_value):
28+
def set_from_cli(self, cli_value: Any) -> None:
2729
self._value = cli_value
2830

29-
def get_for_api(self):
31+
def get_for_api(self) -> Any:
3032
return self._value
3133

3234

3335
class ListAttribute(GitlabAttribute):
34-
def set_from_cli(self, cli_value):
36+
def set_from_cli(self, cli_value: str) -> None:
3537
if not cli_value.strip():
3638
self._value = []
3739
else:
3840
self._value = [item.strip() for item in cli_value.split(",")]
3941

40-
def get_for_api(self):
42+
def get_for_api(self) -> str:
4143
# Do not comma-split single value passed as string
4244
if isinstance(self._value, str):
4345
return self._value
4446

47+
if TYPE_CHECKING:
48+
assert isinstance(self._value, list)
4549
return ",".join([str(x) for x in self._value])
4650

4751

4852
class LowercaseStringAttribute(GitlabAttribute):
49-
def get_for_api(self):
53+
def get_for_api(self) -> str:
5054
return str(self._value).lower()
5155

5256

5357
class FileAttribute(GitlabAttribute):
54-
def get_file_name(self, attr_name=None):
58+
def get_file_name(self, attr_name: Optional[str] = None) -> Optional[str]:
5559
return attr_name
5660

5761

5862
class ImageAttribute(FileAttribute):
59-
def get_file_name(self, attr_name=None):
63+
def get_file_name(self, attr_name: Optional[str] = None) -> str:
6064
return "%s.png" % attr_name if attr_name else "image.png"

0 commit comments

Comments
 (0)