Skip to content

Commit

Permalink
Add Y091: Protocol method arg should not be pos-or-kw (#442)
Browse files Browse the repository at this point in the history
Fixes #441

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Shantanu <12621235+hauntsaninja@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent 06a2682 commit a528bc0
Show file tree
Hide file tree
Showing 4 changed files with 54 additions and 9 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Change Log

## Unreleased

New error codes:
* Introduce Y091: Protocol method parameters should not be positional-or-keyword.

## 24.9.0

Bugfixes
Expand Down
1 change: 1 addition & 0 deletions ERRORCODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ recommend only using `--extend-select`, never `--select`.
| Code | Description | Code category
|------|-------------|---------------
| <a id="Y090" href="#Y090">Y090</a> | `tuple[int]` means "a tuple of length 1, in which the sole element is of type `int`". Consider using `tuple[int, ...]` instead, which means "a tuple of arbitrary (possibly 0) length, in which all elements are of type `int`". | Correctness
| <a id="Y091" href="#Y091">Y091</a> | Protocol methods should not have positional-or-keyword parameters. Usually, a positional-only parameter is better.
43 changes: 34 additions & 9 deletions pyi.py
Original file line number Diff line number Diff line change
Expand Up @@ -1742,7 +1742,9 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.generic_visit(node)
self._check_class_bases(node.bases)
self.enclosing_class_ctx = old_context
self.check_class_pass_and_ellipsis(node)

def check_class_pass_and_ellipsis(self, node: ast.ClassDef) -> None:
# empty class body should contain "..." not "pass"
if len(node.body) == 1:
statement = node.body[0]
Expand Down Expand Up @@ -2086,7 +2088,11 @@ def _check_class_method_for_bad_typevars(
):
self._Y019_error(method, cls_typevar)

def check_self_typevars(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
def check_self_typevars(
self,
node: ast.FunctionDef | ast.AsyncFunctionDef,
decorator_names: Container[str],
) -> None:
pos_or_keyword_args = node.args.posonlyargs + node.args.args

if not pos_or_keyword_args:
Expand All @@ -2100,12 +2106,6 @@ def check_self_typevars(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> N
if not isinstance(first_arg_annotation, (ast.Name, ast.Subscript)):
return

decorator_names = {
decorator.id
for decorator in node.decorator_list
if isinstance(decorator, ast.Name)
}

if "classmethod" in decorator_names or node.name == "__new__":
self._check_class_method_for_bad_typevars(
method=node,
Expand All @@ -2121,6 +2121,20 @@ def check_self_typevars(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> N
return_annotation=return_annotation,
)

def check_protocol_param_kinds(
self,
node: ast.FunctionDef | ast.AsyncFunctionDef,
decorator_names: Container[str],
) -> None:
if "staticmethod" in decorator_names:
relevant_params = node.args.args
else:
relevant_params = node.args.args[1:] # exclude "self"
for pos_or_kw in relevant_params:
if pos_or_kw.arg.startswith("__"):
continue
self.error(pos_or_kw, Y091.format(arg=pos_or_kw.arg, method=node.name))

@staticmethod
def _is_positional_pre_570_argname(name: str) -> bool:
# https://peps.python.org/pep-0484/#positional-only-arguments
Expand Down Expand Up @@ -2175,7 +2189,14 @@ def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:

self._check_pep570_syntax_used_where_applicable(node)
if self.enclosing_class_ctx is not None:
self.check_self_typevars(node)
decorator_names = {
decorator.id
for decorator in node.decorator_list
if isinstance(decorator, ast.Name)
}
self.check_self_typevars(node, decorator_names)
if self.enclosing_class_ctx.is_protocol_class:
self.check_protocol_param_kinds(node, decorator_names)

def visit_arg(self, node: ast.arg) -> None:
if _is_NoReturn(node.annotation):
Expand Down Expand Up @@ -2413,5 +2434,9 @@ def parse_options(options: argparse.Namespace) -> None:
'"a tuple of length 1, in which the sole element is of type {typ!r}". '
'Perhaps you meant "{new}"?'
)
Y091 = (
'Y091 Argument "{arg}" to protocol method "{method}" should probably not be positional-or-keyword. '
"Make it positional-only, since usually you don't want to mandate a specific argument name"
)

DISABLED_BY_DEFAULT = ["Y090"]
DISABLED_BY_DEFAULT = ["Y090", "Y091"]
14 changes: 14 additions & 0 deletions tests/protocol_arg.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# flags: --extend-select=Y091
from typing import Protocol

class P(Protocol):
def method1(self, arg: int) -> None: ... # Y091 Argument "arg" to protocol method "method1" should probably not be positional-or-keyword. Make it positional-only, since usually you don't want to mandate a specific argument name
def method2(self, arg: str, /) -> None: ...
def method3(self, *, arg: str) -> None: ...
def method4(self, arg: int, /) -> None: ...
def method5(self, arg: int, /, *, foo: str) -> None: ...
# Ensure Y091 recognizes this as pos-only for the benefit of users still
# using the old syntax.
def method6(self, __arg: int) -> None: ... # Y063 Use PEP-570 syntax to indicate positional-only arguments
@staticmethod
def smethod1(arg: int) -> None: ... # Y091 Argument "arg" to protocol method "smethod1" should probably not be positional-or-keyword. Make it positional-only, since usually you don't want to mandate a specific argument name

0 comments on commit a528bc0

Please sign in to comment.