| # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html |
| # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE |
| # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt |
| |
| """Imports checkers for Python code.""" |
| |
| from __future__ import annotations |
| |
| import collections |
| import copy |
| import os |
| import sys |
| from collections import defaultdict |
| from collections.abc import ItemsView, Sequence |
| from functools import cached_property |
| from typing import TYPE_CHECKING, Any, Dict, List, Union |
| |
| import astroid |
| from astroid import nodes |
| from astroid.nodes._base_nodes import ImportNode |
| |
| from pylint.checkers import BaseChecker, DeprecatedMixin |
| from pylint.checkers.utils import ( |
| get_import_name, |
| in_type_checking_block, |
| is_from_fallback_block, |
| is_module_ignored, |
| is_sys_guard, |
| node_ignores_exception, |
| ) |
| from pylint.constants import MAX_NUMBER_OF_IMPORT_SHOWN |
| from pylint.exceptions import EmptyReportError |
| from pylint.graph import DotBackend, get_cycles |
| from pylint.interfaces import HIGH |
| from pylint.reporters.ureports.nodes import Paragraph, Section, VerbatimText |
| from pylint.typing import MessageDefinitionTuple |
| from pylint.utils import IsortDriver |
| from pylint.utils.linterstats import LinterStats |
| |
| if TYPE_CHECKING: |
| from pylint.lint import PyLinter |
| |
| |
| # The dictionary with Any should actually be a _ImportTree again |
| # but mypy doesn't support recursive types yet |
| _ImportTree = Dict[str, Union[List[Dict[str, Any]], List[str]]] |
| |
| DEPRECATED_MODULES = { |
| (0, 0, 0): {"tkinter.tix", "fpectl"}, |
| (3, 2, 0): {"optparse"}, |
| (3, 3, 0): {"xml.etree.cElementTree"}, |
| (3, 4, 0): {"imp"}, |
| (3, 5, 0): {"formatter"}, |
| (3, 6, 0): {"asynchat", "asyncore", "smtpd"}, |
| (3, 7, 0): {"macpath"}, |
| (3, 9, 0): {"lib2to3", "parser", "symbol", "binhex"}, |
| (3, 10, 0): {"distutils", "typing.io", "typing.re"}, |
| (3, 11, 0): { |
| "aifc", |
| "audioop", |
| "cgi", |
| "cgitb", |
| "chunk", |
| "crypt", |
| "imghdr", |
| "msilib", |
| "mailcap", |
| "nis", |
| "nntplib", |
| "ossaudiodev", |
| "pipes", |
| "sndhdr", |
| "spwd", |
| "sunau", |
| "sre_compile", |
| "sre_constants", |
| "sre_parse", |
| "telnetlib", |
| "uu", |
| "xdrlib", |
| }, |
| } |
| |
| |
| def _get_first_import( |
| node: ImportNode, |
| context: nodes.LocalsDictNodeNG, |
| name: str, |
| base: str | None, |
| level: int | None, |
| alias: str | None, |
| ) -> tuple[nodes.Import | nodes.ImportFrom | None, str | None]: |
| """Return the node where [base.]<name> is imported or None if not found.""" |
| fullname = f"{base}.{name}" if base else name |
| |
| first = None |
| found = False |
| msg = "reimported" |
| |
| for first in context.body: |
| if first is node: |
| continue |
| if first.scope() is node.scope() and first.fromlineno > node.fromlineno: |
| continue |
| if isinstance(first, nodes.Import): |
| if any(fullname == iname[0] for iname in first.names): |
| found = True |
| break |
| for imported_name, imported_alias in first.names: |
| if not imported_alias and imported_name == alias: |
| found = True |
| msg = "shadowed-import" |
| break |
| if found: |
| break |
| elif isinstance(first, nodes.ImportFrom): |
| if level == first.level: |
| for imported_name, imported_alias in first.names: |
| if fullname == f"{first.modname}.{imported_name}": |
| found = True |
| break |
| if ( |
| name != "*" |
| and name == imported_name |
| and not (alias or imported_alias) |
| ): |
| found = True |
| break |
| if not imported_alias and imported_name == alias: |
| found = True |
| msg = "shadowed-import" |
| break |
| if found: |
| break |
| if found and not astroid.are_exclusive(first, node): |
| return first, msg |
| return None, None |
| |
| |
| def _ignore_import_failure( |
| node: ImportNode, |
| modname: str, |
| ignored_modules: Sequence[str], |
| ) -> bool: |
| if is_module_ignored(modname, ignored_modules): |
| return True |
| |
| # Ignore import failure if part of guarded import block |
| # I.e. `sys.version_info` or `typing.TYPE_CHECKING` |
| if in_type_checking_block(node): |
| return True |
| if isinstance(node.parent, nodes.If) and is_sys_guard(node.parent): |
| return True |
| |
| return node_ignores_exception(node, ImportError) |
| |
| |
| # utilities to represents import dependencies as tree and dot graph ########### |
| |
| |
| def _make_tree_defs(mod_files_list: ItemsView[str, set[str]]) -> _ImportTree: |
| """Get a list of 2-uple (module, list_of_files_which_import_this_module), |
| it will return a dictionary to represent this as a tree. |
| """ |
| tree_defs: _ImportTree = {} |
| for mod, files in mod_files_list: |
| node: list[_ImportTree | list[str]] = [tree_defs, []] |
| for prefix in mod.split("."): |
| assert isinstance(node[0], dict) |
| node = node[0].setdefault(prefix, ({}, [])) # type: ignore[arg-type,assignment] |
| assert isinstance(node[1], list) |
| node[1].extend(files) |
| return tree_defs |
| |
| |
| def _repr_tree_defs(data: _ImportTree, indent_str: str | None = None) -> str: |
| """Return a string which represents imports as a tree.""" |
| lines = [] |
| nodes_items = data.items() |
| for i, (mod, (sub, files)) in enumerate(sorted(nodes_items, key=lambda x: x[0])): |
| files_list = "" if not files else f"({','.join(sorted(files))})" |
| if indent_str is None: |
| lines.append(f"{mod} {files_list}") |
| sub_indent_str = " " |
| else: |
| lines.append(rf"{indent_str}\-{mod} {files_list}") |
| if i == len(nodes_items) - 1: |
| sub_indent_str = f"{indent_str} " |
| else: |
| sub_indent_str = f"{indent_str}| " |
| if sub and isinstance(sub, dict): |
| lines.append(_repr_tree_defs(sub, sub_indent_str)) |
| return "\n".join(lines) |
| |
| |
| def _dependencies_graph(filename: str, dep_info: dict[str, set[str]]) -> str: |
| """Write dependencies as a dot (graphviz) file.""" |
| done = {} |
| printer = DotBackend(os.path.splitext(os.path.basename(filename))[0], rankdir="LR") |
| printer.emit('URL="." node[shape="box"]') |
| for modname, dependencies in sorted(dep_info.items()): |
| sorted_dependencies = sorted(dependencies) |
| done[modname] = 1 |
| printer.emit_node(modname) |
| for depmodname in sorted_dependencies: |
| if depmodname not in done: |
| done[depmodname] = 1 |
| printer.emit_node(depmodname) |
| for depmodname, dependencies in sorted(dep_info.items()): |
| for modname in sorted(dependencies): |
| printer.emit_edge(modname, depmodname) |
| return printer.generate(filename) |
| |
| |
| def _make_graph( |
| filename: str, dep_info: dict[str, set[str]], sect: Section, gtype: str |
| ) -> None: |
| """Generate a dependencies graph and add some information about it in the |
| report's section. |
| """ |
| outputfile = _dependencies_graph(filename, dep_info) |
| sect.append(Paragraph((f"{gtype}imports graph has been written to {outputfile}",))) |
| |
| |
| # the import checker itself ################################################### |
| |
| MSGS: dict[str, MessageDefinitionTuple] = { |
| "E0401": ( |
| "Unable to import %s", |
| "import-error", |
| "Used when pylint has been unable to import a module.", |
| {"old_names": [("F0401", "old-import-error")]}, |
| ), |
| "E0402": ( |
| "Attempted relative import beyond top-level package", |
| "relative-beyond-top-level", |
| "Used when a relative import tries to access too many levels " |
| "in the current package.", |
| ), |
| "R0401": ( |
| "Cyclic import (%s)", |
| "cyclic-import", |
| "Used when a cyclic import between two or more modules is detected.", |
| ), |
| "R0402": ( |
| "Use 'from %s import %s' instead", |
| "consider-using-from-import", |
| "Emitted when a submodule of a package is imported and " |
| "aliased with the same name, " |
| "e.g., instead of ``import concurrent.futures as futures`` use " |
| "``from concurrent import futures``.", |
| ), |
| "W0401": ( |
| "Wildcard import %s", |
| "wildcard-import", |
| "Used when `from module import *` is detected.", |
| ), |
| "W0404": ( |
| "Reimport %r (imported line %s)", |
| "reimported", |
| "Used when a module is imported more than once.", |
| ), |
| "W0406": ( |
| "Module import itself", |
| "import-self", |
| "Used when a module is importing itself.", |
| ), |
| "W0407": ( |
| "Prefer importing %r instead of %r", |
| "preferred-module", |
| "Used when a module imported has a preferred replacement module.", |
| ), |
| "W0410": ( |
| "__future__ import is not the first non docstring statement", |
| "misplaced-future", |
| "Python 2.5 and greater require __future__ import to be the " |
| "first non docstring statement in the module.", |
| ), |
| "C0410": ( |
| "Multiple imports on one line (%s)", |
| "multiple-imports", |
| "Used when import statement importing multiple modules is detected.", |
| ), |
| "C0411": ( |
| "%s should be placed before %s", |
| "wrong-import-order", |
| "Used when PEP8 import order is not respected (standard imports " |
| "first, then third-party libraries, then local imports).", |
| ), |
| "C0412": ( |
| "Imports from package %s are not grouped", |
| "ungrouped-imports", |
| "Used when imports are not grouped by packages.", |
| ), |
| "C0413": ( |
| 'Import "%s" should be placed at the top of the module', |
| "wrong-import-position", |
| "Used when code and imports are mixed.", |
| ), |
| "C0414": ( |
| "Import alias does not rename original package", |
| "useless-import-alias", |
| "Used when an import alias is same as original package, " |
| "e.g., using import numpy as numpy instead of import numpy as np.", |
| ), |
| "C0415": ( |
| "Import outside toplevel (%s)", |
| "import-outside-toplevel", |
| "Used when an import statement is used anywhere other than the module " |
| "toplevel. Move this import to the top of the file.", |
| ), |
| "W0416": ( |
| "Shadowed %r (imported line %s)", |
| "shadowed-import", |
| "Used when a module is aliased with a name that shadows another import.", |
| ), |
| } |
| |
| |
| DEFAULT_STANDARD_LIBRARY = () |
| DEFAULT_KNOWN_THIRD_PARTY = ("enchant",) |
| DEFAULT_PREFERRED_MODULES = () |
| |
| |
| class ImportsChecker(DeprecatedMixin, BaseChecker): |
| """BaseChecker for import statements. |
| |
| Checks for |
| * external modules dependencies |
| * relative / wildcard imports |
| * cyclic imports |
| * uses of deprecated modules |
| * uses of modules instead of preferred modules |
| """ |
| |
| name = "imports" |
| msgs = {**DeprecatedMixin.DEPRECATED_MODULE_MESSAGE, **MSGS} |
| default_deprecated_modules = () |
| |
| options = ( |
| ( |
| "deprecated-modules", |
| { |
| "default": default_deprecated_modules, |
| "type": "csv", |
| "metavar": "<modules>", |
| "help": "Deprecated modules which should not be used," |
| " separated by a comma.", |
| }, |
| ), |
| ( |
| "preferred-modules", |
| { |
| "default": DEFAULT_PREFERRED_MODULES, |
| "type": "csv", |
| "metavar": "<module:preferred-module>", |
| "help": "Couples of modules and preferred modules," |
| " separated by a comma.", |
| }, |
| ), |
| ( |
| "import-graph", |
| { |
| "default": "", |
| "type": "path", |
| "metavar": "<file.gv>", |
| "help": "Output a graph (.gv or any supported image format) of" |
| " all (i.e. internal and external) dependencies to the given file" |
| " (report RP0402 must not be disabled).", |
| }, |
| ), |
| ( |
| "ext-import-graph", |
| { |
| "default": "", |
| "type": "path", |
| "metavar": "<file.gv>", |
| "help": "Output a graph (.gv or any supported image format)" |
| " of external dependencies to the given file" |
| " (report RP0402 must not be disabled).", |
| }, |
| ), |
| ( |
| "int-import-graph", |
| { |
| "default": "", |
| "type": "path", |
| "metavar": "<file.gv>", |
| "help": "Output a graph (.gv or any supported image format)" |
| " of internal dependencies to the given file" |
| " (report RP0402 must not be disabled).", |
| }, |
| ), |
| ( |
| "known-standard-library", |
| { |
| "default": DEFAULT_STANDARD_LIBRARY, |
| "type": "csv", |
| "metavar": "<modules>", |
| "help": "Force import order to recognize a module as part of " |
| "the standard compatibility libraries.", |
| }, |
| ), |
| ( |
| "known-third-party", |
| { |
| "default": DEFAULT_KNOWN_THIRD_PARTY, |
| "type": "csv", |
| "metavar": "<modules>", |
| "help": "Force import order to recognize a module as part of " |
| "a third party library.", |
| }, |
| ), |
| ( |
| "allow-any-import-level", |
| { |
| "default": (), |
| "type": "csv", |
| "metavar": "<modules>", |
| "help": ( |
| "List of modules that can be imported at any level, not just " |
| "the top level one." |
| ), |
| }, |
| ), |
| ( |
| "allow-wildcard-with-all", |
| { |
| "default": False, |
| "type": "yn", |
| "metavar": "<y or n>", |
| "help": "Allow wildcard imports from modules that define __all__.", |
| }, |
| ), |
| ( |
| "allow-reexport-from-package", |
| { |
| "default": False, |
| "type": "yn", |
| "metavar": "<y or n>", |
| "help": "Allow explicit reexports by alias from a package __init__.", |
| }, |
| ), |
| ) |
| |
| def __init__(self, linter: PyLinter) -> None: |
| BaseChecker.__init__(self, linter) |
| self.import_graph: defaultdict[str, set[str]] = defaultdict(set) |
| self._imports_stack: list[tuple[ImportNode, str]] = [] |
| self._first_non_import_node = None |
| self._module_pkg: dict[Any, Any] = ( |
| {} |
| ) # mapping of modules to the pkg they belong in |
| self._allow_any_import_level: set[Any] = set() |
| self.reports = ( |
| ("RP0401", "External dependencies", self._report_external_dependencies), |
| ("RP0402", "Modules dependencies graph", self._report_dependencies_graph), |
| ) |
| self._excluded_edges: defaultdict[str, set[str]] = defaultdict(set) |
| |
| def open(self) -> None: |
| """Called before visiting project (i.e set of modules).""" |
| self.linter.stats.dependencies = {} |
| self.linter.stats = self.linter.stats |
| self.import_graph = defaultdict(set) |
| self._module_pkg = {} # mapping of modules to the pkg they belong in |
| self._current_module_package = False |
| self._ignored_modules: Sequence[str] = self.linter.config.ignored_modules |
| # Build a mapping {'module': 'preferred-module'} |
| self.preferred_modules = dict( |
| module.split(":") |
| for module in self.linter.config.preferred_modules |
| if ":" in module |
| ) |
| self._allow_any_import_level = set(self.linter.config.allow_any_import_level) |
| self._allow_reexport_package = self.linter.config.allow_reexport_from_package |
| |
| def _import_graph_without_ignored_edges(self) -> defaultdict[str, set[str]]: |
| filtered_graph = copy.deepcopy(self.import_graph) |
| for node in filtered_graph: |
| filtered_graph[node].difference_update(self._excluded_edges[node]) |
| return filtered_graph |
| |
| def close(self) -> None: |
| """Called before visiting project (i.e set of modules).""" |
| if self.linter.is_message_enabled("cyclic-import"): |
| graph = self._import_graph_without_ignored_edges() |
| vertices = list(graph) |
| for cycle in get_cycles(graph, vertices=vertices): |
| self.add_message("cyclic-import", args=" -> ".join(cycle)) |
| |
| def get_map_data( |
| self, |
| ) -> tuple[defaultdict[str, set[str]], defaultdict[str, set[str]]]: |
| if self.linter.is_message_enabled("cyclic-import"): |
| return (self.import_graph, self._excluded_edges) |
| return (defaultdict(set), defaultdict(set)) |
| |
| def reduce_map_data( |
| self, |
| linter: PyLinter, |
| data: list[tuple[defaultdict[str, set[str]], defaultdict[str, set[str]]]], |
| ) -> None: |
| if self.linter.is_message_enabled("cyclic-import"): |
| self.import_graph = defaultdict(set) |
| self._excluded_edges = defaultdict(set) |
| for to_update in data: |
| graph, excluded_edges = to_update |
| self.import_graph.update(graph) |
| self._excluded_edges.update(excluded_edges) |
| |
| self.close() |
| |
| def deprecated_modules(self) -> set[str]: |
| """Callback returning the deprecated modules.""" |
| # First get the modules the user indicated |
| all_deprecated_modules = set(self.linter.config.deprecated_modules) |
| # Now get the hard-coded ones from the stdlib |
| for since_vers, mod_set in DEPRECATED_MODULES.items(): |
| if since_vers <= sys.version_info: |
| all_deprecated_modules = all_deprecated_modules.union(mod_set) |
| return all_deprecated_modules |
| |
| def visit_module(self, node: nodes.Module) -> None: |
| """Store if current module is a package, i.e. an __init__ file.""" |
| self._current_module_package = node.package |
| |
| def visit_import(self, node: nodes.Import) -> None: |
| """Triggered when an import statement is seen.""" |
| self._check_reimport(node) |
| self._check_import_as_rename(node) |
| self._check_toplevel(node) |
| |
| names = [name for name, _ in node.names] |
| if len(names) >= 2: |
| self.add_message("multiple-imports", args=", ".join(names), node=node) |
| |
| for name in names: |
| self.check_deprecated_module(node, name) |
| self._check_preferred_module(node, name) |
| imported_module = self._get_imported_module(node, name) |
| if isinstance(node.parent, nodes.Module): |
| # Allow imports nested |
| self._check_position(node) |
| if isinstance(node.scope(), nodes.Module): |
| self._record_import(node, imported_module) |
| |
| if imported_module is None: |
| continue |
| |
| self._add_imported_module(node, imported_module.name) |
| |
| def visit_importfrom(self, node: nodes.ImportFrom) -> None: |
| """Triggered when a from statement is seen.""" |
| basename = node.modname |
| imported_module = self._get_imported_module(node, basename) |
| absolute_name = get_import_name(node, basename) |
| |
| self._check_import_as_rename(node) |
| self._check_misplaced_future(node) |
| self.check_deprecated_module(node, absolute_name) |
| self._check_preferred_module(node, basename) |
| self._check_wildcard_imports(node, imported_module) |
| self._check_same_line_imports(node) |
| self._check_reimport(node, basename=basename, level=node.level) |
| self._check_toplevel(node) |
| |
| if isinstance(node.parent, nodes.Module): |
| # Allow imports nested |
| self._check_position(node) |
| if isinstance(node.scope(), nodes.Module): |
| self._record_import(node, imported_module) |
| if imported_module is None: |
| return |
| for name, _ in node.names: |
| if name != "*": |
| self._add_imported_module(node, f"{imported_module.name}.{name}") |
| else: |
| self._add_imported_module(node, imported_module.name) |
| |
| def leave_module(self, node: nodes.Module) -> None: |
| # Check imports are grouped by category (standard, 3rd party, local) |
| std_imports, ext_imports, loc_imports = self._check_imports_order(node) |
| |
| # Check that imports are grouped by package within a given category |
| met_import: set[str] = set() # set for 'import x' style |
| met_from: set[str] = set() # set for 'from x import y' style |
| current_package = None |
| for import_node, import_name in std_imports + ext_imports + loc_imports: |
| met = met_from if isinstance(import_node, nodes.ImportFrom) else met_import |
| package, _, _ = import_name.partition(".") |
| if ( |
| current_package |
| and current_package != package |
| and package in met |
| and not in_type_checking_block(import_node) |
| and not ( |
| isinstance(import_node.parent, nodes.If) |
| and is_sys_guard(import_node.parent) |
| ) |
| ): |
| self.add_message("ungrouped-imports", node=import_node, args=package) |
| current_package = package |
| if not self.linter.is_message_enabled( |
| "ungrouped-imports", import_node.fromlineno |
| ): |
| continue |
| met.add(package) |
| |
| self._imports_stack = [] |
| self._first_non_import_node = None |
| |
| def compute_first_non_import_node( |
| self, |
| node: ( |
| nodes.If |
| | nodes.Expr |
| | nodes.Comprehension |
| | nodes.IfExp |
| | nodes.Assign |
| | nodes.AssignAttr |
| | nodes.Try |
| ), |
| ) -> None: |
| # if the node does not contain an import instruction, and if it is the |
| # first node of the module, keep a track of it (all the import positions |
| # of the module will be compared to the position of this first |
| # instruction) |
| if self._first_non_import_node: |
| return |
| if not isinstance(node.parent, nodes.Module): |
| return |
| if isinstance(node, nodes.Try) and any( |
| node.nodes_of_class((nodes.Import, nodes.ImportFrom)) |
| ): |
| return |
| if isinstance(node, nodes.Assign): |
| # Add compatibility for module level dunder names |
| # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names |
| valid_targets = [ |
| isinstance(target, nodes.AssignName) |
| and target.name.startswith("__") |
| and target.name.endswith("__") |
| for target in node.targets |
| ] |
| if all(valid_targets): |
| return |
| self._first_non_import_node = node |
| |
| visit_try = visit_assignattr = visit_assign = visit_ifexp = visit_comprehension = ( |
| visit_expr |
| ) = visit_if = compute_first_non_import_node |
| |
| def visit_functiondef( |
| self, node: nodes.FunctionDef | nodes.While | nodes.For | nodes.ClassDef |
| ) -> None: |
| # If it is the first non import instruction of the module, record it. |
| if self._first_non_import_node: |
| return |
| |
| # Check if the node belongs to an `If` or a `Try` block. If they |
| # contain imports, skip recording this node. |
| if not isinstance(node.parent.scope(), nodes.Module): |
| return |
| |
| root = node |
| while not isinstance(root.parent, nodes.Module): |
| root = root.parent |
| |
| if isinstance(root, (nodes.If, nodes.Try)): |
| if any(root.nodes_of_class((nodes.Import, nodes.ImportFrom))): |
| return |
| |
| self._first_non_import_node = node |
| |
| visit_classdef = visit_for = visit_while = visit_functiondef |
| |
| def _check_misplaced_future(self, node: nodes.ImportFrom) -> None: |
| basename = node.modname |
| if basename == "__future__": |
| # check if this is the first non-docstring statement in the module |
| prev = node.previous_sibling() |
| if prev: |
| # consecutive future statements are possible |
| if not ( |
| isinstance(prev, nodes.ImportFrom) and prev.modname == "__future__" |
| ): |
| self.add_message("misplaced-future", node=node) |
| |
| def _check_same_line_imports(self, node: nodes.ImportFrom) -> None: |
| # Detect duplicate imports on the same line. |
| names = (name for name, _ in node.names) |
| counter = collections.Counter(names) |
| for name, count in counter.items(): |
| if count > 1: |
| self.add_message("reimported", node=node, args=(name, node.fromlineno)) |
| |
| def _check_position(self, node: ImportNode) -> None: |
| """Check `node` import or importfrom node position is correct. |
| |
| Send a message if `node` comes before another instruction |
| """ |
| # if a first non-import instruction has already been encountered, |
| # it means the import comes after it and therefore is not well placed |
| if self._first_non_import_node: |
| if self.linter.is_message_enabled( |
| "wrong-import-position", self._first_non_import_node.fromlineno |
| ): |
| self.add_message( |
| "wrong-import-position", node=node, args=node.as_string() |
| ) |
| else: |
| self.linter.add_ignored_message( |
| "wrong-import-position", node.fromlineno, node |
| ) |
| |
| def _record_import( |
| self, |
| node: ImportNode, |
| importedmodnode: nodes.Module | None, |
| ) -> None: |
| """Record the package `node` imports from.""" |
| if isinstance(node, nodes.ImportFrom): |
| importedname = node.modname |
| else: |
| importedname = importedmodnode.name if importedmodnode else None |
| if not importedname: |
| importedname = node.names[0][0].split(".")[0] |
| |
| if isinstance(node, nodes.ImportFrom) and (node.level or 0) >= 1: |
| # We need the importedname with first point to detect local package |
| # Example of node: |
| # 'from .my_package1 import MyClass1' |
| # the output should be '.my_package1' instead of 'my_package1' |
| # Example of node: |
| # 'from . import my_package2' |
| # the output should be '.my_package2' instead of '{pyfile}' |
| importedname = "." + importedname |
| |
| self._imports_stack.append((node, importedname)) |
| |
| @staticmethod |
| def _is_fallback_import( |
| node: ImportNode, imports: list[tuple[ImportNode, str]] |
| ) -> bool: |
| imports = [import_node for (import_node, _) in imports] |
| return any(astroid.are_exclusive(import_node, node) for import_node in imports) |
| |
| # pylint: disable = too-many-statements |
| def _check_imports_order(self, _module_node: nodes.Module) -> tuple[ |
| list[tuple[ImportNode, str]], |
| list[tuple[ImportNode, str]], |
| list[tuple[ImportNode, str]], |
| ]: |
| """Checks imports of module `node` are grouped by category. |
| |
| Imports must follow this order: standard, 3rd party, local |
| """ |
| std_imports: list[tuple[ImportNode, str]] = [] |
| third_party_imports: list[tuple[ImportNode, str]] = [] |
| first_party_imports: list[tuple[ImportNode, str]] = [] |
| # need of a list that holds third or first party ordered import |
| external_imports: list[tuple[ImportNode, str]] = [] |
| local_imports: list[tuple[ImportNode, str]] = [] |
| third_party_not_ignored: list[tuple[ImportNode, str]] = [] |
| first_party_not_ignored: list[tuple[ImportNode, str]] = [] |
| local_not_ignored: list[tuple[ImportNode, str]] = [] |
| isort_driver = IsortDriver(self.linter.config) |
| for node, modname in self._imports_stack: |
| if modname.startswith("."): |
| package = "." + modname.split(".")[1] |
| else: |
| package = modname.split(".")[0] |
| nested = not isinstance(node.parent, nodes.Module) |
| ignore_for_import_order = not self.linter.is_message_enabled( |
| "wrong-import-order", node.fromlineno |
| ) |
| import_category = isort_driver.place_module(package) |
| node_and_package_import = (node, package) |
| |
| if import_category in {"FUTURE", "STDLIB"}: |
| std_imports.append(node_and_package_import) |
| wrong_import = ( |
| third_party_not_ignored |
| or first_party_not_ignored |
| or local_not_ignored |
| ) |
| if self._is_fallback_import(node, wrong_import): |
| continue |
| if wrong_import and not nested: |
| self.add_message( |
| "wrong-import-order", |
| node=node, |
| args=( ## TODO - this isn't right for multiple on the same line... |
| f'standard import "{self._get_full_import_name((node, package))}"', |
| self._get_out_of_order_string( |
| third_party_not_ignored, |
| first_party_not_ignored, |
| local_not_ignored, |
| ), |
| ), |
| ) |
| elif import_category == "THIRDPARTY": |
| third_party_imports.append(node_and_package_import) |
| external_imports.append(node_and_package_import) |
| if not nested: |
| if not ignore_for_import_order: |
| third_party_not_ignored.append(node_and_package_import) |
| else: |
| self.linter.add_ignored_message( |
| "wrong-import-order", node.fromlineno, node |
| ) |
| wrong_import = first_party_not_ignored or local_not_ignored |
| if wrong_import and not nested: |
| self.add_message( |
| "wrong-import-order", |
| node=node, |
| args=( |
| f'third party import "{self._get_full_import_name((node, package))}"', |
| self._get_out_of_order_string( |
| None, first_party_not_ignored, local_not_ignored |
| ), |
| ), |
| ) |
| elif import_category == "FIRSTPARTY": |
| first_party_imports.append(node_and_package_import) |
| external_imports.append(node_and_package_import) |
| if not nested: |
| if not ignore_for_import_order: |
| first_party_not_ignored.append(node_and_package_import) |
| else: |
| self.linter.add_ignored_message( |
| "wrong-import-order", node.fromlineno, node |
| ) |
| wrong_import = local_not_ignored |
| if wrong_import and not nested: |
| self.add_message( |
| "wrong-import-order", |
| node=node, |
| args=( |
| f'first party import "{self._get_full_import_name((node, package))}"', |
| self._get_out_of_order_string( |
| None, None, local_not_ignored |
| ), |
| ), |
| ) |
| elif import_category == "LOCALFOLDER": |
| local_imports.append((node, package)) |
| if not nested: |
| if not ignore_for_import_order: |
| local_not_ignored.append((node, package)) |
| else: |
| self.linter.add_ignored_message( |
| "wrong-import-order", node.fromlineno, node |
| ) |
| return std_imports, external_imports, local_imports |
| |
| def _get_out_of_order_string( |
| self, |
| third_party_imports: list[tuple[ImportNode, str]] | None, |
| first_party_imports: list[tuple[ImportNode, str]] | None, |
| local_imports: list[tuple[ImportNode, str]] | None, |
| ) -> str: |
| # construct the string listing out of order imports used in the message |
| # for wrong-import-order |
| if third_party_imports: |
| plural = "s" if len(third_party_imports) > 1 else "" |
| if len(third_party_imports) > MAX_NUMBER_OF_IMPORT_SHOWN: |
| imports_list = ( |
| ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in third_party_imports[ |
| : int(MAX_NUMBER_OF_IMPORT_SHOWN // 2) |
| ] |
| ] |
| ) |
| + " (...) " |
| + ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in third_party_imports[ |
| int(-MAX_NUMBER_OF_IMPORT_SHOWN // 2) : |
| ] |
| ] |
| ) |
| ) |
| else: |
| imports_list = ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in third_party_imports |
| ] |
| ) |
| third_party = f"third party import{plural} {imports_list}" |
| else: |
| third_party = "" |
| |
| if first_party_imports: |
| plural = "s" if len(first_party_imports) > 1 else "" |
| if len(first_party_imports) > MAX_NUMBER_OF_IMPORT_SHOWN: |
| imports_list = ( |
| ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in first_party_imports[ |
| : int(MAX_NUMBER_OF_IMPORT_SHOWN // 2) |
| ] |
| ] |
| ) |
| + " (...) " |
| + ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in first_party_imports[ |
| int(-MAX_NUMBER_OF_IMPORT_SHOWN // 2) : |
| ] |
| ] |
| ) |
| ) |
| else: |
| imports_list = ", ".join( |
| [ |
| f'"{self._get_full_import_name(fpi)}"' |
| for fpi in first_party_imports |
| ] |
| ) |
| first_party = f"first party import{plural} {imports_list}" |
| else: |
| first_party = "" |
| |
| if local_imports: |
| plural = "s" if len(local_imports) > 1 else "" |
| if len(local_imports) > MAX_NUMBER_OF_IMPORT_SHOWN: |
| imports_list = ( |
| ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in local_imports[ |
| : int(MAX_NUMBER_OF_IMPORT_SHOWN // 2) |
| ] |
| ] |
| ) |
| + " (...) " |
| + ", ".join( |
| [ |
| f'"{self._get_full_import_name(tpi)}"' |
| for tpi in local_imports[ |
| int(-MAX_NUMBER_OF_IMPORT_SHOWN // 2) : |
| ] |
| ] |
| ) |
| ) |
| else: |
| imports_list = ", ".join( |
| [f'"{self._get_full_import_name(li)}"' for li in local_imports] |
| ) |
| local = f"local import{plural} {imports_list}" |
| else: |
| local = "" |
| |
| delimiter_third_party = ( |
| ( |
| ", " |
| if (first_party and local) |
| else (" and " if (first_party or local) else "") |
| ) |
| if third_party |
| else "" |
| ) |
| delimiter_first_party1 = ( |
| (", " if (third_party and local) else " ") if first_party else "" |
| ) |
| delimiter_first_party2 = ("and " if local else "") if first_party else "" |
| delimiter_first_party = f"{delimiter_first_party1}{delimiter_first_party2}" |
| msg = ( |
| f"{third_party}{delimiter_third_party}" |
| f"{first_party}{delimiter_first_party}" |
| f'{local if local else ""}' |
| ) |
| |
| return msg |
| |
| def _get_full_import_name(self, importNode: ImportNode) -> str: |
| # construct a more descriptive name of the import |
| # for: import X, this returns X |
| # for: import X.Y this returns X.Y |
| # for: from X import Y, this returns X.Y |
| |
| try: |
| # this will only succeed for ImportFrom nodes, which in themselves |
| # contain the information needed to reconstruct the package |
| return f"{importNode[0].modname}.{importNode[0].names[0][0]}" |
| except AttributeError: |
| # in all other cases, the import will either be X or X.Y |
| node: str = importNode[0].names[0][0] |
| package: str = importNode[1] |
| |
| if node.split(".")[0] == package: |
| # this is sufficient with one import per line, since package = X |
| # and node = X.Y or X |
| return node |
| |
| # when there is a node that contains multiple imports, the "current" |
| # import being analyzed is specified by package (node is the first |
| # import on the line and therefore != package in this case) |
| return package |
| |
| def _get_imported_module( |
| self, importnode: ImportNode, modname: str |
| ) -> nodes.Module | None: |
| try: |
| return importnode.do_import_module(modname) |
| except astroid.TooManyLevelsError: |
| if _ignore_import_failure(importnode, modname, self._ignored_modules): |
| return None |
| self.add_message("relative-beyond-top-level", node=importnode) |
| except astroid.AstroidSyntaxError as exc: |
| message = f"Cannot import {modname!r} due to '{exc.error}'" |
| self.add_message( |
| "syntax-error", line=importnode.lineno, args=message, confidence=HIGH |
| ) |
| |
| except astroid.AstroidBuildingError: |
| if not self.linter.is_message_enabled("import-error"): |
| return None |
| if _ignore_import_failure(importnode, modname, self._ignored_modules): |
| return None |
| if ( |
| not self.linter.config.analyse_fallback_blocks |
| and is_from_fallback_block(importnode) |
| ): |
| return None |
| |
| dotted_modname = get_import_name(importnode, modname) |
| self.add_message("import-error", args=repr(dotted_modname), node=importnode) |
| except Exception as e: # pragma: no cover |
| raise astroid.AstroidError from e |
| return None |
| |
| def _add_imported_module(self, node: ImportNode, importedmodname: str) -> None: |
| """Notify an imported module, used to analyze dependencies.""" |
| module_file = node.root().file |
| context_name = node.root().name |
| base = os.path.splitext(os.path.basename(module_file))[0] |
| |
| try: |
| if isinstance(node, nodes.ImportFrom) and node.level: |
| importedmodname = astroid.modutils.get_module_part( |
| importedmodname, module_file |
| ) |
| else: |
| importedmodname = astroid.modutils.get_module_part(importedmodname) |
| except ImportError: |
| pass |
| |
| if context_name == importedmodname: |
| self.add_message("import-self", node=node) |
| |
| elif not astroid.modutils.is_stdlib_module(importedmodname): |
| # if this is not a package __init__ module |
| if base != "__init__" and context_name not in self._module_pkg: |
| # record the module's parent, or the module itself if this is |
| # a top level module, as the package it belongs to |
| self._module_pkg[context_name] = context_name.rsplit(".", 1)[0] |
| |
| # handle dependencies |
| dependencies_stat: dict[str, set[str]] = self.linter.stats.dependencies |
| importedmodnames = dependencies_stat.setdefault(importedmodname, set()) |
| if context_name not in importedmodnames: |
| importedmodnames.add(context_name) |
| |
| # update import graph |
| self.import_graph[context_name].add(importedmodname) |
| if not self.linter.is_message_enabled( |
| "cyclic-import", line=node.lineno |
| ) or in_type_checking_block(node): |
| self._excluded_edges[context_name].add(importedmodname) |
| |
| def _check_preferred_module(self, node: ImportNode, mod_path: str) -> None: |
| """Check if the module has a preferred replacement.""" |
| mod_compare = [mod_path] |
| # build a comparison list of possible names using importfrom |
| if isinstance(node, astroid.nodes.node_classes.ImportFrom): |
| mod_compare = [f"{node.modname}.{name[0]}" for name in node.names] |
| |
| # find whether there are matches with the import vs preferred_modules keys |
| matches = [ |
| k |
| for k in self.preferred_modules |
| for mod in mod_compare |
| # exact match |
| if k == mod |
| # checks for base module matches |
| or k in mod.split(".")[0] |
| ] |
| |
| # if we have matches, add message |
| if matches: |
| self.add_message( |
| "preferred-module", |
| node=node, |
| args=(self.preferred_modules[matches[0]], matches[0]), |
| ) |
| |
| def _check_import_as_rename(self, node: ImportNode) -> None: |
| names = node.names |
| for name in names: |
| if not all(name): |
| return |
| |
| splitted_packages = name[0].rsplit(".", maxsplit=1) |
| import_name = splitted_packages[-1] |
| aliased_name = name[1] |
| if import_name != aliased_name: |
| continue |
| |
| if len(splitted_packages) == 1 and ( |
| self._allow_reexport_package is False |
| or self._current_module_package is False |
| ): |
| self.add_message("useless-import-alias", node=node, confidence=HIGH) |
| elif len(splitted_packages) == 2: |
| self.add_message( |
| "consider-using-from-import", |
| node=node, |
| args=(splitted_packages[0], import_name), |
| ) |
| |
| def _check_reimport( |
| self, |
| node: ImportNode, |
| basename: str | None = None, |
| level: int | None = None, |
| ) -> None: |
| """Check if a module with the same name is already imported or aliased.""" |
| if not self.linter.is_message_enabled( |
| "reimported" |
| ) and not self.linter.is_message_enabled("shadowed-import"): |
| return |
| |
| frame = node.frame() |
| root = node.root() |
| contexts = [(frame, level)] |
| if root is not frame: |
| contexts.append((root, None)) |
| |
| for known_context, known_level in contexts: |
| for name, alias in node.names: |
| first, msg = _get_first_import( |
| node, known_context, name, basename, known_level, alias |
| ) |
| if first is not None and msg is not None: |
| name = name if msg == "reimported" else alias |
| self.add_message( |
| msg, node=node, args=(name, first.fromlineno), confidence=HIGH |
| ) |
| |
| def _report_external_dependencies( |
| self, sect: Section, _: LinterStats, _dummy: LinterStats | None |
| ) -> None: |
| """Return a verbatim layout for displaying dependencies.""" |
| dep_info = _make_tree_defs(self._external_dependencies_info.items()) |
| if not dep_info: |
| raise EmptyReportError() |
| tree_str = _repr_tree_defs(dep_info) |
| sect.append(VerbatimText(tree_str)) |
| |
| def _report_dependencies_graph( |
| self, sect: Section, _: LinterStats, _dummy: LinterStats | None |
| ) -> None: |
| """Write dependencies as a dot (graphviz) file.""" |
| dep_info = self.linter.stats.dependencies |
| if not dep_info or not ( |
| self.linter.config.import_graph |
| or self.linter.config.ext_import_graph |
| or self.linter.config.int_import_graph |
| ): |
| raise EmptyReportError() |
| filename = self.linter.config.import_graph |
| if filename: |
| _make_graph(filename, dep_info, sect, "") |
| filename = self.linter.config.ext_import_graph |
| if filename: |
| _make_graph(filename, self._external_dependencies_info, sect, "external ") |
| filename = self.linter.config.int_import_graph |
| if filename: |
| _make_graph(filename, self._internal_dependencies_info, sect, "internal ") |
| |
| def _filter_dependencies_graph(self, internal: bool) -> defaultdict[str, set[str]]: |
| """Build the internal or the external dependency graph.""" |
| graph: defaultdict[str, set[str]] = defaultdict(set) |
| for importee, importers in self.linter.stats.dependencies.items(): |
| for importer in importers: |
| package = self._module_pkg.get(importer, importer) |
| is_inside = importee.startswith(package) |
| if is_inside and internal or not is_inside and not internal: |
| graph[importee].add(importer) |
| return graph |
| |
| @cached_property |
| def _external_dependencies_info(self) -> defaultdict[str, set[str]]: |
| """Return cached external dependencies information or build and |
| cache them. |
| """ |
| return self._filter_dependencies_graph(internal=False) |
| |
| @cached_property |
| def _internal_dependencies_info(self) -> defaultdict[str, set[str]]: |
| """Return cached internal dependencies information or build and |
| cache them. |
| """ |
| return self._filter_dependencies_graph(internal=True) |
| |
| def _check_wildcard_imports( |
| self, node: nodes.ImportFrom, imported_module: nodes.Module | None |
| ) -> None: |
| if node.root().package: |
| # Skip the check if in __init__.py issue #2026 |
| return |
| |
| wildcard_import_is_allowed = self._wildcard_import_is_allowed(imported_module) |
| for name, _ in node.names: |
| if name == "*" and not wildcard_import_is_allowed: |
| self.add_message("wildcard-import", args=node.modname, node=node) |
| |
| def _wildcard_import_is_allowed(self, imported_module: nodes.Module | None) -> bool: |
| return ( |
| self.linter.config.allow_wildcard_with_all |
| and imported_module is not None |
| and "__all__" in imported_module.locals |
| ) |
| |
| def _check_toplevel(self, node: ImportNode) -> None: |
| """Check whether the import is made outside the module toplevel.""" |
| # If the scope of the import is a module, then obviously it is |
| # not outside the module toplevel. |
| if isinstance(node.scope(), nodes.Module): |
| return |
| |
| module_names = [ |
| ( |
| f"{node.modname}.{name[0]}" |
| if isinstance(node, nodes.ImportFrom) |
| else name[0] |
| ) |
| for name in node.names |
| ] |
| |
| # Get the full names of all the imports that are only allowed at the module level |
| scoped_imports = [ |
| name for name in module_names if name not in self._allow_any_import_level |
| ] |
| |
| if scoped_imports: |
| self.add_message( |
| "import-outside-toplevel", args=", ".join(scoped_imports), node=node |
| ) |
| |
| |
| def register(linter: PyLinter) -> None: |
| linter.register_checker(ImportsChecker(linter)) |