Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an option to flag forbidden imports on the line at which they occur #411

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

* Introduce a new flag, `--precise-import-code-linenos`. On Python 3.10 and
higher, the flag means that import-related error codes such as Y022 and
Y027 are now emitted on the line of the specific import that is disallowed,
rather than the first line of the import statement. The flag has no effect
on Python 3.9 and lower.
* Introduce Y090, which warns if you have an annotation such as `tuple[int]` or
`Tuple[int]`. These mean "a tuple of length 1, in which the sole element is
of type `int`". This is sometimes what you want, but more usually you'll want
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,50 @@ have an explicit dependency on either `typing_extensions` or typeshed.

Flake8-pyi's checks may produce false positives on stubs that aim to support Python 2.

## Optional flags

### `--no-pyi-aware-file-checker`

This flag disables the behaviour where flake8-pyi modifies pyflakes runs for `.pyi`
files. It can be set on the command line or in a configuration file. It defaults to
`False`.

### `--precise-import-code-linenos`

The default behaviour of flake8-pyi is to flag "forbidden imports" on the first line
of a multiline import statement, e.g.:

```py
from collections.abc import ( # Y057 flagged on this line here for the bad ByteString import
Awaitable,
ByteString,
Generator,
Iterable,
Iterator,
Mapping,
MutableMapping,
MutableSequence,
```

Enabling `--precise-import-code-linenos` will mean that flake8-pyi will use more
precise line numbers for "forbidden imports" on Python 3.10 and higher. The flag has
no effect on Python 3.9 and lower:

```py
from collections.abc import (
Awaitable,
ByteString, # Y057 flagged on this line here for the bad ByteString import with --precise-import-code-linenos
Generator,
Iterable,
Iterator,
Mapping,
MutableMapping,
MutableSequence,
```

This flag defaults to `False`. It can be set on the command line or in a configuration
file.

## License

MIT
Expand Down
83 changes: 53 additions & 30 deletions pyi.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,9 @@ def active(self) -> bool:


class PyiVisitor(ast.NodeVisitor):
# This is dynamically set on the class in PyiTreeChecker.parse_options()
precise_import_linenos: ClassVar[bool]

filename: str
errors: list[Error]

Expand Down Expand Up @@ -902,67 +905,68 @@ def __init__(self, filename: str) -> None:
def __repr__(self) -> str:
return f"{self.__class__.__name__}(filename={self.filename!r})"

def _check_import_or_attribute(
self, node: ast.Attribute | ast.ImportFrom, module_name: str, object_name: str
) -> None:
@staticmethod
def _check_import_or_attribute(module_name: str, object_name: str) -> str | None:
"""Return the relevant error message for a bad import or attribute access.

If the import/attribute access is OK, return None.
"""
fullname = f"{module_name}.{object_name}"

# Y057 errors
if fullname in {"typing.ByteString", "collections.abc.ByteString"}:
error_message = Y057.format(module=module_name)
return Y057.format(module=module_name)

# Y022 errors
elif fullname in _BAD_Y022_IMPORTS:
if fullname in _BAD_Y022_IMPORTS:
good_cls_name, slice_contents = _BAD_Y022_IMPORTS[fullname]
params = "" if slice_contents is None else f"[{slice_contents}]"
error_message = Y022.format(
return Y022.format(
good_syntax=f'"{good_cls_name}{params}"',
bad_syntax=f'"{fullname}{params}"',
)

# Y023 errors
elif module_name == "typing_extensions":
if module_name == "typing_extensions":
if object_name in _BAD_TYPINGEXTENSIONS_Y023_IMPORTS:
error_message = Y023.format(
return Y023.format(
good_syntax=f'"typing.{object_name}"',
bad_syntax=f'"typing_extensions.{object_name}"',
)
elif object_name == "ClassVar":
error_message = Y023.format(
return Y023.format(
good_syntax='"typing.ClassVar[T]"',
bad_syntax='"typing_extensions.ClassVar[T]"',
)
else:
return
return None

# Y024 errors
elif fullname == "collections.namedtuple":
error_message = Y024
if fullname == "collections.namedtuple":
return Y024

# Y037 errors
elif fullname == "typing.Optional":
error_message = Y037.format(
if fullname == "typing.Optional":
return Y037.format(
old_syntax=fullname, example='"int | None" instead of "Optional[int]"'
)
elif fullname == "typing.Union":
error_message = Y037.format(
if fullname == "typing.Union":
return Y037.format(
old_syntax=fullname, example='"int | str" instead of "Union[int, str]"'
)

# Y039 errors
elif fullname == "typing.Text":
error_message = Y039
if fullname == "typing.Text":
return Y039

else:
return

self.error(node, error_message)
return None

def visit_Attribute(self, node: ast.Attribute) -> None:
self.generic_visit(node)
self._check_import_or_attribute(
node=node, module_name=unparse(node.value), object_name=node.attr
)
if error_msg := self._check_import_or_attribute(
module_name=unparse(node.value), object_name=node.attr
):
self.error(node, error_msg)

def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
self.generic_visit(node)
Expand All @@ -971,25 +975,34 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if module_name is None:
return

def error_for_bad_import(
node: ast.ImportFrom, subnode: ast.alias, msg: str
) -> None:
if self.precise_import_linenos:
self.error(subnode, msg)
else:
self.error(node, msg)

imported_names = {obj.name: obj for obj in node.names}

if module_name == "__future__":
if "annotations" in imported_names:
self.error(node, Y044)
error_for_bad_import(node, imported_names["annotations"], Y044)
return

if (
module_name == "collections.abc"
and "Set" in imported_names
and imported_names["Set"].asname != "AbstractSet"
):
self.error(node, Y025)
error_for_bad_import(node, imported_names["Set"], Y025)

for object_name in imported_names:
self._check_import_or_attribute(node, module_name, object_name)
for object_name, subnode in imported_names.items():
if error_msg := self._check_import_or_attribute(module_name, object_name):
error_for_bad_import(node, subnode, error_msg)

if module_name == "typing" and "AbstractSet" in imported_names:
self.error(node, Y038)
error_for_bad_import(node, imported_names["AbstractSet"], Y038)

def _check_for_typevarlike_assignments(
self, node: ast.Assign, function: ast.expr, object_name: str
Expand Down Expand Up @@ -2024,11 +2037,21 @@ def add_options(parser: OptionManager) -> None:
parse_from_config=True,
help="don't patch flake8 with .pyi-aware file checker",
)
parser.add_option(
"--precise-import-code-linenos",
default=False,
action="store_true",
parse_from_config=True,
help="use precise linenos for import-related lints, where possible",
)

@staticmethod
def parse_options(options: argparse.Namespace) -> None:
if not options.no_pyi_aware_file_checker:
checker.FileChecker = PyiAwareFileChecker
PyiVisitor.precise_import_linenos = (
options.precise_import_code_linenos and sys.version_info >= (3, 10)
)


# Please keep error code lists in ERRORCODES and CHANGELOG up to date
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ path = "pyi.py"
[tool.isort]
profile = "black"
combine_as_imports = true
skip = ["tests/imports.pyi"]
skip_gitignore = true

[tool.black]
Expand Down
4 changes: 1 addition & 3 deletions tests/imports.pyi
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# isort: skip_file
# flags: --extend-ignore=F401,F811
#
# Note: DO NOT RUN ISORT ON THIS FILE.
# It's excluded in our pyproject.toml.

# BAD IMPORTS (Y044)
from __future__ import annotations # Y044 "from __future__ import annotations" has no effect in stub files.
Expand Down
36 changes: 36 additions & 0 deletions tests/precise_import_linenos_py310.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# isort: skip_file
# flags: --precise-import-code-linenos --extend-ignore=F401

from __future__ import (
absolute_import,
annotations, # Y044 "from __future__ import annotations" has no effect in stub files.
barry_as_FLUFL,
division,
generators,
nested_scopes,
unicode_literals,
with_statement,
)
from collections.abc import (
Awaitable,
ByteString, # Y057 Do not use collections.abc.ByteString, which has unclear semantics and is deprecated
Generator,
Iterable,
Iterator,
Mapping,
MutableMapping,
MutableSequence,
Sequence,
Set, # Y025 Use "from collections.abc import Set as AbstractSet" to avoid confusion with "builtins.set"
)
from typing import (
AbstractSet, # Y038 Use "from collections.abc import Set as AbstractSet" instead of "from typing import AbstractSet" (PEP 585 syntax)
Annotated,
ClassVar,
Final,
NewType,
Self,
final,
overload,
type_check_only,
)