# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/PyCQA/pylint/blob/main/LICENSE # Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt """Check for imports on private external modules and names.""" from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from astroid import nodes from pylint.checkers import BaseChecker, utils from pylint.interfaces import HIGH if TYPE_CHECKING: from pylint.lint.pylinter import PyLinter class PrivateImportChecker(BaseChecker): name = "import-private-name" msgs = { "C2701": ( "Imported private %s (%s)", "import-private-name", "Used when a private module or object prefixed with _ is imported. " "PEP8 guidance on Naming Conventions states that public attributes with " "leading underscores should be considered private.", ), } def __init__(self, linter: PyLinter) -> None: BaseChecker.__init__(self, linter) # A mapping of private names used as a type annotation to whether it is an acceptable import self.all_used_type_annotations: dict[str, bool] = {} self.populated_annotations = False @utils.only_required_for_messages("import-private-name") def visit_import(self, node: nodes.Import) -> None: if utils.in_type_checking_block(node): return names = [name[0] for name in node.names] private_names = self._get_private_imports(names) private_names = self._get_type_annotation_names(node, private_names) if private_names: imported_identifier = "modules" if len(private_names) > 1 else "module" private_name_string = ", ".join(private_names) self.add_message( "import-private-name", node=node, args=(imported_identifier, private_name_string), confidence=HIGH, ) @utils.only_required_for_messages("import-private-name") def visit_importfrom(self, node: nodes.ImportFrom) -> None: if utils.in_type_checking_block(node): return # Only check imported names if the module is external if self.same_root_dir(node, node.modname): return names = [n[0] for n in node.names] # Check the imported objects first. If they are all valid type annotations, # the package can be private private_names = self._get_type_annotation_names(node, names) if not private_names: return # There are invalid imported objects, so check the name of the package private_module_imports = self._get_private_imports([node.modname]) private_module_imports = self._get_type_annotation_names( node, private_module_imports ) if private_module_imports: self.add_message( "import-private-name", node=node, args=("module", private_module_imports[0]), confidence=HIGH, ) return # Do not emit messages on the objects if the package is private private_names = self._get_private_imports(private_names) if private_names: imported_identifier = "objects" if len(private_names) > 1 else "object" private_name_string = ", ".join(private_names) self.add_message( "import-private-name", node=node, args=(imported_identifier, private_name_string), confidence=HIGH, ) def _get_private_imports(self, names: list[str]) -> list[str]: """Returns the private names from input names by a simple string check.""" return [name for name in names if self._name_is_private(name)] @staticmethod def _name_is_private(name: str) -> bool: """Returns true if the name exists, starts with `_`, and if len(name) > 4 it is not a dunder, i.e. it does not begin and end with two underscores. """ return ( bool(name) and name[0] == "_" and (len(name) <= 4 or name[1] != "_" or name[-2:] != "__") ) def _get_type_annotation_names( self, node: nodes.Import | nodes.ImportFrom, names: list[str] ) -> list[str]: """Removes from names any names that are used as type annotations with no other illegal usages. """ if names and not self.populated_annotations: self._populate_type_annotations(node.root(), self.all_used_type_annotations) self.populated_annotations = True return [ n for n in names if n not in self.all_used_type_annotations or ( n in self.all_used_type_annotations and not self.all_used_type_annotations[n] ) ] def _populate_type_annotations( self, node: nodes.LocalsDictNodeNG, all_used_type_annotations: dict[str, bool] ) -> None: """Adds to `all_used_type_annotations` all names ever used as a type annotation in the node's (nested) scopes and whether they are only used as annotation. """ for name in node.locals: # If we find a private type annotation, make sure we do not mask illegal usages private_name = None # All the assignments using this variable that we might have to check for # illegal usages later name_assignments = [] for usage_node in node.locals[name]: if isinstance(usage_node, nodes.AssignName) and isinstance( usage_node.parent, (nodes.AnnAssign, nodes.Assign) ): assign_parent = usage_node.parent if isinstance(assign_parent, nodes.AnnAssign): name_assignments.append(assign_parent) private_name = self._populate_type_annotations_annotation( usage_node.parent.annotation, all_used_type_annotations ) elif isinstance(assign_parent, nodes.Assign): name_assignments.append(assign_parent) if isinstance(usage_node, nodes.FunctionDef): self._populate_type_annotations_function( usage_node, all_used_type_annotations ) if isinstance(usage_node, nodes.LocalsDictNodeNG): self._populate_type_annotations( usage_node, all_used_type_annotations ) if private_name is not None: # Found a new private annotation, make sure we are not accessing it elsewhere all_used_type_annotations[ private_name ] = self._assignments_call_private_name(name_assignments, private_name) def _populate_type_annotations_function( self, node: nodes.FunctionDef, all_used_type_annotations: dict[str, bool] ) -> None: """Adds all names used as type annotation in the arguments and return type of the function node into the dict `all_used_type_annotations`. """ if node.args and node.args.annotations: for annotation in node.args.annotations: self._populate_type_annotations_annotation( annotation, all_used_type_annotations ) if node.returns: self._populate_type_annotations_annotation( node.returns, all_used_type_annotations ) def _populate_type_annotations_annotation( self, node: nodes.Attribute | nodes.Subscript | nodes.Name | None, all_used_type_annotations: dict[str, bool], ) -> str | None: """Handles the possibility of an annotation either being a Name, i.e. just type, or a Subscript e.g. `Optional[type]` or an Attribute, e.g. `pylint.lint.linter`. """ if isinstance(node, nodes.Name) and node.name not in all_used_type_annotations: all_used_type_annotations[node.name] = True return node.name # type: ignore[no-any-return] if isinstance(node, nodes.Subscript): # e.g. Optional[List[str]] # slice is the next nested type self._populate_type_annotations_annotation( node.slice, all_used_type_annotations ) # value is the current type name: could be a Name or Attribute return self._populate_type_annotations_annotation( node.value, all_used_type_annotations ) if isinstance(node, nodes.Attribute): # An attribute is a type like `pylint.lint.pylinter`. node.expr is the next level # up, could be another attribute return self._populate_type_annotations_annotation( node.expr, all_used_type_annotations ) return None @staticmethod def _assignments_call_private_name( assignments: list[nodes.AnnAssign | nodes.Assign], private_name: str ) -> bool: """Returns True if no assignments involve accessing `private_name`.""" if all(not assignment.value for assignment in assignments): # Variable annotated but unassigned is not allowed because there may be # possible illegal access elsewhere return False for assignment in assignments: current_attribute = None if isinstance(assignment.value, nodes.Call): current_attribute = assignment.value.func elif isinstance(assignment.value, nodes.Attribute): current_attribute = assignment.value elif isinstance(assignment.value, nodes.Name): current_attribute = assignment.value.name if not current_attribute: continue while isinstance(current_attribute, (nodes.Attribute, nodes.Call)): if isinstance(current_attribute, nodes.Call): current_attribute = current_attribute.func if not isinstance(current_attribute, nodes.Name): current_attribute = current_attribute.expr if ( isinstance(current_attribute, nodes.Name) and current_attribute.name == private_name ): return False return True @staticmethod def same_root_dir( node: nodes.Import | nodes.ImportFrom, import_mod_name: str ) -> bool: """Does the node's file's path contain the base name of `import_mod_name`?""" if not import_mod_name: # from . import ... return True if node.level: # from .foo import ..., from ..bar import ... return True base_import_package = import_mod_name.split(".")[0] return base_import_package in Path(node.root().file).parent.parts def register(linter: PyLinter) -> None: linter.register_checker(PrivateImportChecker(linter))