summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ChangeLog5
-rw-r--r--doc/whatsnew/2.13.rst4
-rw-r--r--pylint/checkers/__init__.py3
-rw-r--r--pylint/checkers/utils.py5
-rw-r--r--pylint/extensions/private_import.py248
-rw-r--r--tests/extensions/test_private_import.py69
-rw-r--r--tests/functional/ext/private_import/private_import.py121
-rw-r--r--tests/functional/ext/private_import/private_import.rc2
-rw-r--r--tests/functional/ext/private_import/private_import.txt20
9 files changed, 476 insertions, 1 deletions
diff --git a/ChangeLog b/ChangeLog
index 726ccc134..52cb627f2 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -54,6 +54,11 @@ Release date: TBA
closes #5722
+* New extension ``import-private-name``: indicate imports of external private packages
+ and objects (prefixed with ``_``). It can be loaded using ``load-plugins=pylint.extensions.private_import``.
+
+ Closes #5463
+
* Fixed crash from ``arguments-differ`` and ``arguments-renamed`` when methods were
defined outside the top level of a class.
diff --git a/doc/whatsnew/2.13.rst b/doc/whatsnew/2.13.rst
index 130a9ac69..d277d0d82 100644
--- a/doc/whatsnew/2.13.rst
+++ b/doc/whatsnew/2.13.rst
@@ -76,6 +76,10 @@ Removed checkers
Extensions
==========
+* New extension ``import-private-name``: indicate imports of external private packages
+ and objects (prefixed with ``_``). It can be loaded using ``load-plugins=pylint.extensions.private_import``.
+
+ Closes #5463
* Pyreverse - add output in mermaid-js format and html which is an mermaid js diagram with html boilerplate
diff --git a/pylint/checkers/__init__.py b/pylint/checkers/__init__.py
index 160bad8af..61ce67ffe 100644
--- a/pylint/checkers/__init__.py
+++ b/pylint/checkers/__init__.py
@@ -45,7 +45,8 @@ Base id of standard checkers (used in msg and report ids):
24: non-ascii-names
25: unicode
26: unsupported_version
-27-50: not yet used: reserved for future internal checkers.
+27: private-import
+28-50: not yet used: reserved for future internal checkers.
This file is not updated. Use
script/get_unused_message_id_category.py
to get the next free checker id.
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 4e775e3d2..f53ae8845 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -1664,6 +1664,11 @@ def is_typing_guard(node: nodes.If) -> bool:
) and node.test.as_string().endswith("TYPE_CHECKING")
+def is_node_in_typing_guarded_import_block(node: nodes.NodeNG) -> bool:
+ """Return True if node is part for guarded `typing.TYPE_CHECKING` if block."""
+ return isinstance(node.parent, nodes.If) and is_typing_guard(node.parent)
+
+
def is_node_in_guarded_import_block(node: nodes.NodeNG) -> bool:
"""Return True if node is part for guarded if block.
diff --git a/pylint/extensions/private_import.py b/pylint/extensions/private_import.py
new file mode 100644
index 000000000..88033fa1e
--- /dev/null
+++ b/pylint/extensions/private_import.py
@@ -0,0 +1,248 @@
+"""Check for imports on private external modules and names."""
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, List, Union
+
+from astroid import nodes
+
+from pylint.checkers import BaseChecker, utils
+from pylint.interfaces import HIGH, IAstroidChecker
+
+if TYPE_CHECKING:
+ from pylint.lint.pylinter import PyLinter
+
+
+class PrivateImportChecker(BaseChecker):
+
+ __implements__ = (IAstroidChecker,)
+ 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.check_messages("import-private-name")
+ def visit_import(self, node: nodes.Import) -> None:
+ if utils.is_node_in_typing_guarded_import_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.check_messages("import-private-name")
+ def visit_importfrom(self, node: nodes.ImportFrom) -> None:
+ if utils.is_node_in_typing_guarded_import_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, 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 into the dict `all_used_type_annotations` the names of all names ever used as a type annotation
+ in the scope and nested scopes of node and whether these names are only used for type checking.
+ """
+ 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 into the dict `all_used_type_annotations` the names of all names used as a type annotation
+ in the arguments and return type of the function node.
+ """
+ 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: Union[nodes.Attribute, nodes.Subscript, nodes.Name],
+ all_used_type_annotations: Dict[str, bool],
+ ) -> Union[str, None]:
+ """Handles the possiblity 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
+ 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[Union[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 unallowed because there may be a 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
+ 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, 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
+
+ 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))
diff --git a/tests/extensions/test_private_import.py b/tests/extensions/test_private_import.py
new file mode 100644
index 000000000..10648a8f9
--- /dev/null
+++ b/tests/extensions/test_private_import.py
@@ -0,0 +1,69 @@
+"""Tests the local module directory comparison logic which requires mocking file directories"""
+
+from unittest.mock import patch
+
+import astroid
+
+from pylint.extensions import private_import
+from pylint.interfaces import HIGH
+from pylint.testutils import CheckerTestCase, MessageTest
+
+
+class TestPrivateImport(CheckerTestCase):
+ """The mocked dirname is the directory of the file being linted, the node is code inside that file"""
+
+ CHECKER_CLASS = private_import.PrivateImportChecker
+
+ @patch("pathlib.Path.parent")
+ def test_internal_module(self, parent) -> None:
+ parent.parts = ("", "dir", "module")
+ import_from = astroid.extract_node("""from module import _file""")
+
+ with self.assertNoMessages():
+ self.checker.visit_importfrom(import_from)
+
+ @patch("pathlib.Path.parent")
+ def test_external_module_nested(self, parent) -> None:
+ parent.parts = ("", "dir", "module", "module_files", "util")
+
+ import_from = astroid.extract_node("""from module import _file""")
+
+ with self.assertNoMessages():
+ self.checker.visit_importfrom(import_from)
+
+ @patch("pathlib.Path.parent")
+ def test_external_module_dot_import(self, parent) -> None:
+ parent.parts = ("", "dir", "outer", "inner", "module_files", "util")
+
+ import_from = astroid.extract_node("""from outer.inner import _file""")
+
+ with self.assertNoMessages():
+ self.checker.visit_importfrom(import_from)
+
+ @patch("pathlib.Path.parent")
+ def test_external_module_dot_import_outer_only(self, parent) -> None:
+ parent.parts = ("", "dir", "outer", "extensions")
+
+ import_from = astroid.extract_node("""from outer.inner import _file""")
+
+ with self.assertNoMessages():
+ self.checker.visit_importfrom(import_from)
+
+ @patch("pathlib.Path.parent")
+ def test_external_module(self, parent) -> None:
+ parent.parts = ("", "dir", "other")
+
+ import_from = astroid.extract_node("""from module import _file""")
+
+ msg = MessageTest(
+ msg_id="import-private-name",
+ node=import_from,
+ line=1,
+ col_offset=0,
+ end_line=1,
+ end_col_offset=24,
+ args=("object", "_file"),
+ confidence=HIGH,
+ )
+ with self.assertAddsMessages(msg):
+ self.checker.visit_importfrom(import_from)
diff --git a/tests/functional/ext/private_import/private_import.py b/tests/functional/ext/private_import/private_import.py
new file mode 100644
index 000000000..52ef405c9
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.py
@@ -0,0 +1,121 @@
+"""Tests for import-private-name."""
+# pylint: disable=unused-import, missing-docstring, reimported, import-error, wrong-import-order
+# pylint: disable=no-name-in-module, multiple-imports, ungrouped-imports, misplaced-future
+# pylint: disable=wrong-import-position
+
+# Basic cases
+from _world import hello # [import-private-name]
+from _world import _hello # [import-private-name]
+from city import _house # [import-private-name]
+from city import a, _b, c, _d # [import-private-name]
+from _city import a, _b, c, _d # [import-private-name]
+from city import a, b, c, _d # [import-private-name]
+import house
+import _house # [import-private-name]
+import _house, _chair, _stair # [import-private-name]
+import house, _chair, _stair # [import-private-name]
+
+# Ignore dunders
+import __asd__
+import __future__
+from __future__ import print_function
+from __future__ import __print_function__
+
+# Ignore local modules
+# The check for local modules compares directory names in the path of the file being linted to
+# the name of the module we are importing from. The use of `__init__.py` to indicate Python modules
+# is deprecated so this is a heuristic solution.
+# If we were importing from `pylint`, it would be counted as a valid internal private import
+# and not emit a message as long as this file has a parent directory called `pylint`, even though
+# we are not importing from that directory. (We would be importing from `pylint/pylint`.)
+from private_import import _private # pylint: disable=import-self
+from private_import.other_file import _private
+from . import _private
+from astroid import _private # [import-private-name]
+from sys import _private # [import-private-name]
+
+# Ignore typecheck
+from typing import TYPE_CHECKING, List, Optional
+
+if TYPE_CHECKING:
+ import _TreeType
+ from types import _TreeType
+ from _types import TreeType
+ from _types import _TreeType
+
+# No error since imports are used as type annotations
+from classes import _PrivateClassA, safe_get_A
+from classes import _PrivateClassB
+from classes import _PrivateClassC
+
+a_var: _PrivateClassA = safe_get_A()
+
+def b_func(class_b: _PrivateClassB):
+ print(class_b)
+
+def c_func() -> _PrivateClassC:
+ return None
+
+# Used as typing in slices
+from classes import _SubScriptA
+from classes import _SubScriptB
+
+a: Optional[_SubScriptA]
+b: Optional[_SubScriptB[List]]
+
+import _TypeContainerA
+import _TypeContainerB
+import _TypeContainerC
+
+import SafeContainerA
+a2: _TypeContainerA.A = SafeContainerA.safe_get_a()
+
+def b_func2(class_b2: _TypeContainerB.B):
+ print(class_b2)
+
+def c2_func() -> _TypeContainerC.C:
+ return None
+
+# This is allowed since all the imports are used for typing
+from _TypeContainerExtra import TypeExtraA, TypeExtraB
+from MakerContainerExtra import GetA, GetB
+extraA: TypeExtraA = GetA()
+extraB: TypeExtraB = GetB()
+
+# This is not allowed because there is an import not used for typing
+from _TypeContainerExtra2 import TypeExtra2, NotTypeExtra # [import-private-name]
+extra2: TypeExtra2
+
+# Try many cases to ensure that type annotation usages of a private import
+# do not mask other illegal usages of the import
+import _private_module # [import-private-name]
+my_var: _private_module.Thing = _private_module.Thing()
+
+import _private_module2 # [import-private-name]
+my_var2: _private_module2.Thing2
+my_var2 = _private_module2.Thing2()
+
+import _private_module3 # [import-private-name]
+my_var3: _private_module3.Thing3
+my_var3 = _private_module3.Thing3
+my_var3_2: _private_module3.Thing3
+
+import _private_module4 # [import-private-name]
+my_var4: _private_module4.Thing4
+my_var4 = _private_module4.get_callers().get_thing4()
+
+from _private_module5 import PrivateClass # [import-private-name]
+my_var5: PrivateClass
+my_var5 = PrivateClass()
+
+from _private_module6 import PrivateClass2 # [import-private-name]
+my_var6: PrivateClass2 = PrivateClass2()
+
+from public_module import _PrivateClass3 # [import-private-name]
+my_var7: _PrivateClass3 = _PrivateClass3()
+
+# Even though we do not see the private call, the type check does not keep us from emitting
+# because we do not use that variable
+import _private_module_unreachable # [import-private-name]
+my_var8: _private_module_unreachable.Thing8
+_private_module_unreachable.Thing8()
diff --git a/tests/functional/ext/private_import/private_import.rc b/tests/functional/ext/private_import/private_import.rc
new file mode 100644
index 000000000..c9bbc23f1
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.rc
@@ -0,0 +1,2 @@
+[MASTER]
+load-plugins=pylint.extensions.private_import,
diff --git a/tests/functional/ext/private_import/private_import.txt b/tests/functional/ext/private_import/private_import.txt
new file mode 100644
index 000000000..f618d5858
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.txt
@@ -0,0 +1,20 @@
+import-private-name:7:0:7:24::Imported private module (_world):HIGH
+import-private-name:8:0:8:25::Imported private module (_world):HIGH
+import-private-name:9:0:9:23::Imported private object (_house):HIGH
+import-private-name:10:0:10:29::Imported private objects (_b, _d):HIGH
+import-private-name:11:0:11:30::Imported private module (_city):HIGH
+import-private-name:12:0:12:28::Imported private object (_d):HIGH
+import-private-name:14:0:14:13::Imported private module (_house):HIGH
+import-private-name:15:0:15:29::Imported private modules (_house, _chair, _stair):HIGH
+import-private-name:16:0:16:28::Imported private modules (_chair, _stair):HIGH
+import-private-name:34:0:34:28::Imported private object (_private):HIGH
+import-private-name:35:0:35:24::Imported private object (_private):HIGH
+import-private-name:86:0:86:57::Imported private module (_TypeContainerExtra2):HIGH
+import-private-name:91:0:91:22::Imported private module (_private_module):HIGH
+import-private-name:94:0:94:23::Imported private module (_private_module2):HIGH
+import-private-name:98:0:98:23::Imported private module (_private_module3):HIGH
+import-private-name:103:0:103:23::Imported private module (_private_module4):HIGH
+import-private-name:107:0:107:41::Imported private module (_private_module5):HIGH
+import-private-name:111:0:111:42::Imported private module (_private_module6):HIGH
+import-private-name:114:0:114:40::Imported private object (_PrivateClass3):HIGH
+import-private-name:119:0:119:34::Imported private module (_private_module_unreachable):HIGH