Continued implementing a checker for import statements (import order). eric7

Thu, 02 Dec 2021 18:53:26 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 02 Dec 2021 18:53:26 +0100
branch
eric7
changeset 8802
129a973fc33e
parent 8801
8fbb21be8579
child 8804
bf6eff477756

Continued implementing a checker for import statements (import order).

eric7.epj file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.py file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.ui file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportNode.py file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsChecker.py file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsEnums.py file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/LocalImportVisitor.py file | annotate | diff | comparison | revisions
eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/translations.py file | annotate | diff | comparison | revisions
--- a/eric7.epj	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7.epj	Thu Dec 02 18:53:26 2021 +0100
@@ -2301,7 +2301,9 @@
       "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsChecker.py",
       "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/translations.py",
       "eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerUtilities.py",
-      "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/LocalImportVisitor.py"
+      "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/LocalImportVisitor.py",
+      "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsEnums.py",
+      "eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportNode.py"
     ],
     "SPELLEXCLUDES": "Dictionaries/excludes.dic",
     "SPELLLANGUAGE": "en_US",
--- a/eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.py	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.py	Thu Dec 02 18:53:26 2021 +0100
@@ -548,7 +548,7 @@
         
         if "ImportsChecker" not in self.__data:
             self.__data["ImportsChecker"] = {
-                "ApplicationModuleNames": [],
+                "ApplicationPackageNames": [],
             }
         
         self.__initCategoriesList(self.__data["EnabledCheckerCategories"])
@@ -813,8 +813,8 @@
             }
             
             importsArgs = {
-                "ApplicationModuleNames":
-                    sorted(self.appModulesEdit.toPlainText().split()),
+                "ApplicationPackageNames":
+                    sorted(self.appPackagesEdit.toPlainText().split()),
             }
             
             self.__options = [excludeMessages, includeMessages, repeatMessages,
@@ -1250,8 +1250,8 @@
                         self.typedExceptionsCheckBox.isChecked(),
                 },
                 "ImportsChecker": {
-                    "ApplicationModuleNames":
-                        sorted(self.appModulesEdit.toPlainText().split())
+                    "ApplicationPackageNames":
+                        sorted(self.appPackagesEdit.toPlainText().split())
                 },
             }
             if (
@@ -1596,9 +1596,9 @@
                 SecurityDefaults["check_typed_exception"]))),
         
         # Imports Checker
-        self.appModulesEdit.setPlainText(" ".join(
+        self.appPackagesEdit.setPlainText(" ".join(
             sorted(Preferences.toList(Preferences.getSettings().value(
-                "PEP8/ApplicationModuleNames", [])))))
+                "PEP8/ApplicationPackageNames", [])))))
         
         self.__cleanupData()
     
@@ -1738,8 +1738,8 @@
         
         # Imports Checker
         Preferences.getSettings().setValue(
-            "PEP8/ApplicationModuleNames",
-            sorted(self.appModulesEdit.toPlainText().split()))
+            "PEP8/ApplicationPackageNames",
+            sorted(self.appPackagesEdit.toPlainText().split()))
     
     @pyqtSlot()
     def on_resetDefaultButton_clicked(self):
@@ -1867,7 +1867,7 @@
         
         # Imports Checker
         Preferences.getSettings().setValue(
-            "PEP8/ApplicationModuleNames", [])
+            "PEP8/ApplicationPackageNames", [])
         
         # Update UI with default values
         self.on_loadDefaultButton_clicked()
--- a/eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.ui	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/CodeStyleCheckerDialog.ui	Thu Dec 02 18:53:26 2021 +0100
@@ -267,8 +267,8 @@
                <rect>
                 <x>0</x>
                 <y>0</y>
-                <width>611</width>
-                <height>1417</height>
+                <width>365</width>
+                <height>1152</height>
                </rect>
               </property>
               <layout class="QVBoxLayout" name="verticalLayout_4">
@@ -1261,13 +1261,13 @@
            <item>
             <widget class="QGroupBox" name="groupBox_15">
              <property name="title">
-              <string>Application Modules</string>
+              <string>Application Packages</string>
              </property>
              <layout class="QVBoxLayout" name="verticalLayout_14">
               <item>
                <widget class="QLabel" name="label_35">
                 <property name="text">
-                 <string>Enter top level application module names separated by a space character:</string>
+                 <string>Enter top level application package names separated by a space character:</string>
                 </property>
                 <property name="wordWrap">
                  <bool>true</bool>
@@ -1275,7 +1275,7 @@
                </widget>
               </item>
               <item>
-               <widget class="QPlainTextEdit" name="appModulesEdit"/>
+               <widget class="QPlainTextEdit" name="appPackagesEdit"/>
               </item>
              </layout>
             </widget>
@@ -1707,7 +1707,7 @@
   <tabstop>ecHighRiskCombo</tabstop>
   <tabstop>ecMediumRiskCombo</tabstop>
   <tabstop>typedExceptionsCheckBox</tabstop>
-  <tabstop>appModulesEdit</tabstop>
+  <tabstop>appPackagesEdit</tabstop>
   <tabstop>startButton</tabstop>
   <tabstop>loadDefaultButton</tabstop>
   <tabstop>storeDefaultButton</tabstop>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportNode.py	Thu Dec 02 18:53:26 2021 +0100
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a class representing an import or import from node.
+"""
+
+#
+# adapted from flake8-alphabetize v0.0.17
+#
+
+import ast
+from functools import total_ordering
+
+from .ImportsEnums import GroupEnum, NodeTypeEnum
+
+
+class ImportNodeException(Exception):
+    """
+    Class representing an exception for an invalid import node.
+    """
+    pass
+
+
+@total_ordering
+class ImportNode:
+    """
+    Class representing an import or import from node.
+    """
+    def __init__(self, appNames, astNode, checker):
+        """
+        Constructor
+        
+        @param appNames list of application package names
+        @type list of str
+        @param astNode reference to the ast node
+        @type ast.AST
+        @param checker reference to the checker object
+        @type ImportsChecker
+        @exception ImportNodeException raised to indicate an invalid node was
+            given to this class
+        """
+        if (
+            self.nodeType not in (NodeTypeEnum.IMPORT,
+                                  NodeTypeEnum.IMPORT_FROM)
+        ):
+            raise ImportNodeException(
+                "Node type {0} not recognized".format(type(astNode))
+            )
+        
+        self.node = astNode
+        self.error = None
+        level = None
+        group = None
+        
+        if isinstance(astNode, ast.Import):
+            self.nodeType = NodeTypeEnum.IMPORT
+            names = astNode.names
+
+            self.moduleName = names[0].name
+            level = 0
+        
+        elif isinstance(astNode, ast.ImportFrom):
+            module = astNode.module
+            self.moduleName = "" if module is None else module
+            self.nodeType = NodeTypeEnum.IMPORT_FROM
+
+            names = [n.name for n in astNode.names]
+            expectedNames = sorted(names)
+            if names != expectedNames:
+                self.error = (self.node, "I202", ", ".join(expectedNames))
+            level = astNode.level
+        
+        if self.moduleName == "__future__":
+            group = GroupEnum.FUTURE
+        elif self.moduleName in checker.getStandardModules():
+            group = GroupEnum.STDLIB
+        elif level > 0:
+            group = GroupEnum.APPLICATION
+        else:
+            group = GroupEnum.THIRD_PARTY
+            for name in appNames:
+                if (
+                    name == self.moduleName or
+                    self.moduleName.startswith("{0}.".format(name))
+                ):
+                    group = GroupEnum.APPLICATION
+                    break
+        
+        if group == GroupEnum.STDLIB:
+            self.sorter = group, self.nodeType, self.moduleName
+        else:
+            m = self.moduleName
+            dotIndex = m.find(".")
+            topName = m if dotIndex == -1 else m[:dotIndex]
+            self.sorter = group, level, topName, self.nodeType, m
+    
+    def __eq__(self, other):
+        """
+        Special method implementing the equality operator.
+        
+        @param other reference to the object to compare
+        @type ImportNode
+        @return flag indicating equality
+        @rtype bool
+        """
+        return self.sorter == other.sorter
+    
+    def __lt__(self, other):
+        """
+        Special method implementing the less than operator.
+        
+        @param other reference to the object to compare
+        @type ImportNode
+        @return flag indicating a less than situation
+        @rtype bool
+        """
+        return self.sorter < other.sorter
+    
+    def __str__(self):
+        """
+        Special method to create a string representation of the instance.
+        
+        @return string representation of the instance
+        @rtype str
+        @exception ImportNodeException raised to indicate an invalid node was
+            given to this class
+        """
+        if (
+            self.nodeType not in (NodeTypeEnum.IMPORT,
+                                  NodeTypeEnum.IMPORT_FROM)
+        ):
+            raise ImportNodeException(
+                "The node type {0} is not recognized.".format(self.nodeType)
+            )
+        
+        if self.nodeType == NodeTypeEnum.IMPORT:
+            return "import {0}".format(self.moduleName)
+        elif self.nodeType == NodeTypeEnum.IMPORT_FROM:
+            level = self.node.level
+            levelStr = "" if level == 0 else "." * level
+            names = [
+                n.name + ("" if n.asname is None else
+                          " as {0}".format(n.asname))
+                for n in self.node.names
+            ]
+            return "from {0}{1} import {2}".format(
+                levelStr, self.moduleName, ", ".join(names))
+        
+        return None
--- a/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsChecker.py	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsChecker.py	Thu Dec 02 18:53:26 2021 +0100
@@ -7,6 +7,7 @@
 Module implementing a checker for import statements.
 """
 
+import ast
 import copy
 import sys
 
@@ -18,6 +19,9 @@
     Codes = [
         ## Local imports
         "I101", "I102", "I103",
+        
+        ## Imports order
+        "I201", "I202", "I203", "I204",
     ]
 
     def __init__(self, source, filename, tree, select, ignore, expected,
@@ -59,6 +63,7 @@
         
         checkersWithCodes = [
             (self.__checkLocalImports, ("I101", "I102", "I103")),
+            (self.__checkImportOrder, ("I201", "I202", "I203", "I204"))
         ]
         
         self.__checkers = []
@@ -198,6 +203,121 @@
         visitor = LocalImportVisitor(self.__args, self)
         visitor.visit(copy.deepcopy(self.__tree))
         for violation in visitor.violations:
-            node = violation[0]
-            reason = violation[1]
-            self.__error(node.lineno - 1, node.col_offset, reason)
+            if not self.__ignoreCode(violation[1]):
+                node = violation[0]
+                reason = violation[1]
+                self.__error(node.lineno - 1, node.col_offset, reason)
+    
+    #######################################################################
+    ## Import order
+    ##
+    ## adapted from: flake8-alphabetize v0.0.17
+    #######################################################################
+    
+    def __checkImportOrder(self):
+        """
+        Private method to check the order of import statements.
+        """
+        from .ImportNode import ImportNode
+        
+        errors = []
+        imports = []
+        importNodes, listNode = self.__findNodes(self.__tree)
+        
+        # check for an error in '__all__'
+        allError = self.__findErrorInAll(listNode)
+        if allError is not None:
+            errors.append(allError)
+        
+        for importNode in importNodes:
+            if (
+                isinstance(importNode, ast.Import) and
+                len(importNode.names) > 1
+            ):
+                # skip suck imports because its already handled by pycodestyle
+                continue
+            
+            imports.append(ImportNode(
+                self.__args.get("ApplicationPackageNames", []),
+                importNode, self))
+        
+        lenImports = len(imports)
+        if lenImports > 0:
+            p = imports[0]
+            if p.error is not None:
+                errors.append(p.error)
+            
+            if lenImports > 1:
+                for n in imports[1:]:
+                    if n.error is not None:
+                        errors.append(n.error)
+                    
+                    if n == p:
+                        errors.append((n.node, "I203", str(p), str(n)))
+                    elif n < p:
+                        errors.append((n.node, "I201", str(n), str(p)))
+                    
+                    p = n
+        
+        for error in errors:
+            if not self.__ignoreCode(error[1]):
+                node = error[0]
+                reason = error[1]
+                args = error[2:]
+                self.__error(node.lineno - 1, node.col_offset, reason, *args)
+    
+    def __findNodes(self, tree):
+        """
+        Private method to find all import and import from nodes of the given
+        tree.
+        
+        @param tree reference to the ast node tree to be parsed
+        @type ast.AST
+        @return tuple containing a list of import nodes and the '__all__' node
+        @rtype tuple of (ast.Import | ast.ImportFrom, ast.List | ast.Tuple)
+        """
+        importNodes = []
+        listNode = None
+        
+        if isinstance(tree, ast.Module):
+            body = tree.body
+            
+            for n in body:
+                if isinstance(n, (ast.Import, ast.ImportFrom)):
+                    importNodes.append(n)
+                
+                elif isinstance(n, ast.Assign):
+                    for t in n.targets:
+                        if isinstance(t, ast.Name) and t.id == "__all__":
+                            value = n.value
+
+                            if isinstance(value, (ast.List, ast.Tuple)):
+                                listNode = value
+        
+        return importNodes, listNode
+    
+    def __findErrorInAll(self, node):
+        """
+        Private method to check the '__all__' node for errors.
+        
+        @param node reference to the '__all__' node
+        @type ast.List or ast.Tuple
+        @return tuple containing a reference to the node and an error code
+        @rtype rtype tuple of (ast.List | ast.Tuple, str)
+        """
+        if node is not None:
+            actualList = []
+            for el in node.elts:
+                if isinstance(el, ast.Constant):
+                    actualList.append(el.value)
+                elif isinstance(el, ast.Str):
+                    actualList.append(el.s)
+                else:
+                    # Can't handle anything that isn't a string literal
+                    return None
+
+            expectedList = sorted(actualList)
+            if expectedList != actualList:
+                return (node, "I204", ", ".join(expectedList))
+        
+        return None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/ImportsEnums.py	Thu Dec 02 18:53:26 2021 +0100
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing some enums for the import order checker.
+"""
+
+#
+# adapted from flake8-alphabetize v0.0.17
+#
+
+import enum
+
+
+class GroupEnum(enum.IntEnum):
+    """
+    Class representing the various import groups.
+    """
+    FUTURE = 1
+    STDLIB = 2
+    THIRD_PARTY = 3
+    APPLICATION = 4
+
+
+class NodeTypeEnum(enum.IntEnum):
+    """
+    Class representing the import node types.
+    """
+    IMPORT = 1
+    IMPORT_FROM = 2
--- a/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/LocalImportVisitor.py	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/LocalImportVisitor.py	Thu Dec 02 18:53:26 2021 +0100
@@ -27,7 +27,7 @@
         @param checker reference to the checker
         @type ImportsChecker
         """
-        self.__appImportNames = args.get("ApplicationModuleNames", [])
+        self.__appImportNames = args.get("ApplicationPackageNames", [])
         self.__checker = checker
         
         self.violations = []
--- a/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/translations.py	Wed Dec 01 20:09:57 2021 +0100
+++ b/eric7/Plugins/CheckerPlugins/CodeStyleChecker/Imports/translations.py	Thu Dec 02 18:53:26 2021 +0100
@@ -20,8 +20,28 @@
     "I103": QCoreApplication.translate(
         "ImportsChecker",
         "packages from standard modules should not be imported locally"),
+    
+    "I201": QCoreApplication.translate(
+        "ImportsChecker",
+        "Import statements are in the wrong order. "
+        "'{0}' should be before '{1}'"),
+    "I202": QCoreApplication.translate(
+        "ImportsChecker",
+        "Imported names are in the wrong order. "
+        "Should be {0}"),
+    "I203": QCoreApplication.translate(
+        "ImportsChecker",
+        "Import statements should be combined. "
+        "'{0}' should be combined with '{1}'"),
+    "I204": QCoreApplication.translate(
+        "ImportsChecker",
+        "The names in __all__ are in the wrong order. "
+        "The order should be {0}"),
 }
 
 _importsMessagesSampleArgs = {
-    
+    "I201": ["import os", "import sys"],
+    "I202": ["copy, os, sys"],
+    "I203": ["from foo import bar", "from foo import baz"],
+    "I204": ["bar, baz, foo"],
 }

eric ide

mercurial