3232# stdlib
3333import ast
3434import sys
35- from typing import Any , Generator , Iterator , List , Set , Tuple , Type , Union
35+ from enum import Enum
36+ from typing import TYPE_CHECKING , Any , Generator , Iterator , List , Optional , Sequence , Set , Tuple , Type , Union , cast
3637
3738# 3rd party
39+ import natsort
3840from consolekit .terminal_colours import Fore
3941from domdf_python_tools .paths import PathPlus
4042from domdf_python_tools .typing import PathLike
4143from domdf_python_tools .utils import stderr_writer
44+ from flake8 .options .manager import OptionManager # type: ignore[import]
4245
4346# this package
4447from flake8_dunder_all .utils import find_noqa , get_docstring_lineno , mark_text_ranges
4548
49+ if TYPE_CHECKING :
50+ # stdlib
51+ from argparse import Namespace
52+
4653__author__ : str = "Dominic Davis-Foster"
4754__copyright__ : str = "2020 Dominic Davis-Foster"
4855__license__ : str = "MIT"
4956__version__ : str = "0.4.1"
5057__email__ : str = "dominic@davis-foster.co.uk"
5158
52- __all__ = ("Visitor" , "Plugin" , "check_and_add_all" , "DALL000" )
59+ __all__ = (
60+ "check_and_add_all" ,
61+ "AlphabeticalOptions" ,
62+ "DALL000" ,
63+ "DALL001" ,
64+ "DALL002" ,
65+ "Plugin" ,
66+ "Visitor" ,
67+ )
5368
5469DALL000 = "DALL000 Module lacks __all__."
70+ DALL001 = "DALL001 __all__ not sorted alphabetically"
71+ DALL002 = "DALL002 __all__ not a list or tuple of strings."
72+
73+
74+ class AlphabeticalOptions (Enum ):
75+ """
76+ Enum of possible values for the ``--dunder-all-alphabetical`` option.
77+
78+ .. versionadded:: 0.5.0
79+ """
80+
81+ UPPER = "upper"
82+ LOWER = "lower"
83+ IGNORE = "ignore"
84+ NONE = "none"
5585
5686
5787class Visitor (ast .NodeVisitor ):
@@ -61,30 +91,56 @@ class Visitor(ast.NodeVisitor):
6191 :param use_endlineno: Flag to indicate whether the end_lineno functionality is available.
6292 This functionality is available on Python 3.8 and above, or when the tree has been passed through
6393 :func:`flake8_dunder_all.utils.mark_text_ranges``.
94+
95+ .. versionchanged:: 0.5.0
96+
97+ Added the ``sorted_upper_first``, ``sorted_lower_first`` and ``all_lineno`` attributes.
6498 """
6599
66100 found_all : bool #: Flag to indicate a ``__all__`` declaration has been found in the AST.
67101 last_import : int #: The lineno of the last top-level or conditional import
68102 members : Set [str ] #: List of functions and classed defined in the AST
69103 use_endlineno : bool
104+ all_members : Optional [Sequence [str ]] #: The value of ``__all__``.
105+ all_lineno : int #: The line number where ``__all__`` is defined.
70106
71107 def __init__ (self , use_endlineno : bool = False ) -> None :
72108 self .found_all = False
73109 self .members = set ()
74110 self .last_import = 0
75111 self .use_endlineno = use_endlineno
112+ self .all_members = None
113+ self .all_lineno = - 1
76114
77- def visit_Name (self , node : ast .Name ) -> None :
78- """
79- Visit a variable.
80-
81- :param node: The node being visited.
82- """
115+ def visit_Assign (self , node : ast .Assign ) -> None : # noqa: D102
116+ targets = []
117+ for t in node .targets :
118+ if isinstance (t , ast .Name ):
119+ targets .append (t .id )
83120
84- if node . id == "__all__" :
121+ if "__all__" in targets :
85122 self .found_all = True
86- else :
87- self .generic_visit (node )
123+ self .all_lineno = node .lineno
124+ self .all_members = self ._parse_all (cast (ast .List , node .value ))
125+
126+ def visit_AnnAssign (self , node : ast .AnnAssign ) -> None : # noqa: D102
127+ if isinstance (node .target , ast .Name ):
128+ if node .target .id == "__all__" :
129+ self .all_lineno = node .lineno
130+ self .found_all = True
131+ self .all_members = self ._parse_all (cast (ast .List , node .value ))
132+
133+ @staticmethod
134+ def _parse_all (all_node : ast .List ) -> Optional [Sequence [str ]]:
135+ try :
136+ all_ = ast .literal_eval (all_node )
137+ except ValueError :
138+ return None
139+
140+ if not isinstance (all_ , Sequence ):
141+ return None
142+
143+ return all_
88144
89145 def handle_def (self , node : Union [ast .FunctionDef , ast .AsyncFunctionDef , ast .ClassDef ]) -> None :
90146 """
@@ -252,6 +308,7 @@ class Plugin:
252308
253309 name : str = __name__
254310 version : str = __version__ #: The plugin version
311+ dunder_all_alphabetical : AlphabeticalOptions = AlphabeticalOptions .NONE
255312
256313 def __init__ (self , tree : ast .AST ):
257314 self ._tree = tree
@@ -272,12 +329,50 @@ def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
272329 visitor .visit (self ._tree )
273330
274331 if visitor .found_all :
275- return
332+ if visitor .all_members is None :
333+ yield visitor .all_lineno , 0 , DALL002 , type (self )
334+
335+ elif self .dunder_all_alphabetical == AlphabeticalOptions .IGNORE :
336+ # Alphabetical, upper or lower don't matter
337+ sorted_alphabetical = natsort .natsorted (visitor .all_members , key = str .lower )
338+ if list (visitor .all_members ) != sorted_alphabetical :
339+ yield visitor .all_lineno , 0 , f"{ DALL001 } ." , type (self )
340+ elif self .dunder_all_alphabetical == AlphabeticalOptions .UPPER :
341+ # Alphabetical, uppercase grouped first
342+ sorted_alphabetical = natsort .natsorted (visitor .all_members )
343+ if list (visitor .all_members ) != sorted_alphabetical :
344+ yield visitor .all_lineno , 0 , f"{ DALL001 } (uppercase first)." , type (self )
345+ elif self .dunder_all_alphabetical == AlphabeticalOptions .LOWER :
346+ # Alphabetical, lowercase grouped first
347+ sorted_alphabetical = natsort .natsorted (visitor .all_members , alg = natsort .ns .LOWERCASEFIRST )
348+ if list (visitor .all_members ) != sorted_alphabetical :
349+ yield visitor .all_lineno , 0 , f"{ DALL001 } (lowercase first)." , type (self )
350+
276351 elif not visitor .members :
277352 return
353+
278354 else :
279355 yield 1 , 0 , DALL000 , type (self )
280356
357+ @classmethod
358+ def add_options (cls , option_manager : OptionManager ) -> None : # noqa: D102 # pragma: no cover
359+
360+ option_manager .add_option (
361+ "--dunder-all-alphabetical" ,
362+ choices = [member .value for member in AlphabeticalOptions ],
363+ parse_from_config = True ,
364+ default = AlphabeticalOptions .NONE .value ,
365+ help = (
366+ "Require entries in '__all__' to be alphabetical ([upper] or [lower]case first)."
367+ "(Default: %(default)s)"
368+ ),
369+ )
370+
371+ @classmethod
372+ def parse_options (cls , options : "Namespace" ) -> None : # noqa: D102 # pragma: no cover
373+ # note: this sets the option on the class and not the instance
374+ cls .dunder_all_alphabetical = AlphabeticalOptions (options .dunder_all_alphabetical )
375+
281376
282377def check_and_add_all (filename : PathLike , quote_type : str = '"' , use_tuple : bool = False ) -> int :
283378 """
0 commit comments