Coverage for sources / vibelinter / rules / implementations / vbl201.py: 94%

104 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-01 02:35 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21 

22''' VBL201: Import hub enforcement - no public imports in non-hub modules. 

23 

24 

25 

26 Category: Imports / Architecture 

27 Subcategory: Namespace Management 

28 

29 This rule enforces the import hub pattern by detecting non-private imports 

30 in modules that are not designated as import hubs. All imports must either: 

31 1. Be from __future__ 

32 2. Result in private names (starting with _) 

33 3. Be in a hub module (identified by configurable glob patterns) 

34 

35 This maintains architectural consistency, prevents namespace pollution, 

36 and makes the codebase self-documenting. 

37''' 

38 

39 

40from . import __ 

41 

42 

43class VBL201( __.BaseRule ): 

44 ''' Enforces import hub pattern for non-hub modules. ''' 

45 

46 @property 

47 def rule_id( self ) -> str: 

48 return 'VBL201' 

49 

50 def __init__( 

51 self, 

52 filename: str, 

53 wrapper: __.libcst.metadata.MetadataWrapper, 

54 source_lines: tuple[ str, ... ], 

55 hub_patterns: __.Absential[ tuple[ str, ... ] ] = __.absent, 

56 ) -> None: 

57 super( ).__init__( filename, wrapper, source_lines ) 

58 # Store hub patterns from configuration or use defaults 

59 self._hub_patterns: tuple[ str, ... ] = ( 

60 hub_patterns if not __.is_absent( hub_patterns ) 

61 else ( '__init__.py', '__main__.py', '__.py', '__/imports.py' ) ) 

62 # Determine if this file is a hub module 

63 self._is_hub_module: bool = self._is_import_hub_module( ) 

64 # Track function nesting depth (to allow local imports) 

65 self._function_depth: int = 0 

66 # Collections for violations 

67 self._simple_imports: list[ __.libcst.Import ] = [ ] 

68 self._from_imports: list[ __.libcst.ImportFrom ] = [ ] 

69 

70 def visit_FunctionDef( self, node: __.libcst.FunctionDef ) -> bool: 

71 ''' Tracks entry into function definitions. ''' 

72 self._function_depth += 1 

73 return True 

74 

75 def leave_FunctionDef( 

76 self, original_node: __.libcst.FunctionDef 

77 ) -> None: 

78 ''' Tracks exit from function definitions. ''' 

79 self._function_depth -= 1 

80 

81 def visit_Import( self, node: __.libcst.Import ) -> bool: 

82 ''' Collects module-level simple import statements (import foo). ''' 

83 if self._is_hub_module: 

84 return True 

85 # Allow imports inside function bodies (local imports) 

86 if self._function_depth > 0: 

87 return True 

88 self._simple_imports.append( node ) 

89 return True 

90 

91 def visit_ImportFrom( self, node: __.libcst.ImportFrom ) -> bool: 

92 ''' Collects module-level from imports (from foo import bar). ''' 

93 if self._is_hub_module: 

94 return True 

95 # Allow imports inside function bodies (local imports) 

96 if self._function_depth > 0: 

97 return True 

98 if self._is_future_import( node ): 

99 return True 

100 if self._has_private_names( node ): 

101 return True 

102 self._from_imports.append( node ) 

103 return True 

104 

105 def _analyze_collections( self ) -> None: 

106 ''' Analyzes collected imports and generates violations. ''' 

107 for node in self._simple_imports: 

108 self._report_simple_import_violation( node ) 

109 for node in self._from_imports: 

110 self._report_from_import_violation( node ) 

111 

112 def _is_import_hub_module( self ) -> bool: 

113 ''' Checks if current file matches any hub module pattern. 

114 

115 Uses glob patterns from configuration to identify hub modules. 

116 Patterns are matched against both the filename and full path. 

117 ''' 

118 file_path = __.pathlib.Path( self.filename ) 

119 for pattern in self._hub_patterns: 

120 # Try matching against the file path 

121 if file_path.match( pattern ): 

122 return True 

123 # Try matching with wildcard prefix for path-based patterns 

124 if file_path.match( f'*/{pattern}' ): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 return True 

126 return False 

127 

128 def _is_future_import( self, node: __.libcst.ImportFrom ) -> bool: 

129 ''' Checks if import is from __future__. ''' 

130 module = node.module 

131 if isinstance( module, __.libcst.Attribute ): 

132 return False 

133 if module is None: 

134 return False 

135 return module.value == '__future__' 

136 

137 def _has_private_names( self, node: __.libcst.ImportFrom ) -> bool: 

138 ''' Checks if all imported names are private (start with _). 

139 

140 Examples of allowed imports: 

141 - from . import __ (__ starts with _) 

142 - from . import exceptions as _exceptions (alias starts with _) 

143 - from json import loads as _json_loads (alias starts with _) 

144 

145 Examples of violations: 

146 - from . import exceptions (exceptions doesn't start with _) 

147 - from pathlib import Path (Path doesn't start with _) 

148 - from pathlib import Path as P (P doesn't start with _) 

149 ''' 

150 # Star imports are never private 

151 if isinstance( node.names, __.libcst.ImportStar ): 

152 return False 

153 # Check each imported name 

154 for name in node.names: 

155 # Determine the resulting name in the module namespace 

156 if isinstance( name.asname, __.libcst.AsName ): 

157 # Has alias - check if alias is private 

158 alias_name = name.asname.name 

159 if isinstance( alias_name, __.libcst.Name ): 159 ↛ 162line 159 didn't jump to line 162 because the condition on line 159 was always true

160 resulting_name = alias_name.value 

161 else: 

162 return False 

163 else: 

164 # No alias - check if original name is private 

165 original_name = name.name 

166 if isinstance( original_name, __.libcst.Name ): 166 ↛ 169line 166 didn't jump to line 169 because the condition on line 166 was always true

167 resulting_name = original_name.value 

168 else: 

169 return False 

170 # Must start with underscore to be private 

171 if not resulting_name.startswith( '_' ): 

172 return False 

173 return True 

174 

175 def _report_simple_import_violation( 

176 self, node: __.libcst.Import 

177 ) -> None: 

178 ''' Reports violation for simple import statement. ''' 

179 # Extract module name from import 

180 if node.names: 180 ↛ 187line 180 didn't jump to line 187 because the condition on line 180 was always true

181 module_name = node.names[ 0 ].name.value 

182 message = ( 

183 f"Direct import of '{module_name}'. " 

184 f"Use import hub or private alias." 

185 ) 

186 else: 

187 message = ( 

188 "Direct import detected. Use import hub or private alias." ) 

189 self._produce_violation( node, message, severity = 'warning' ) 

190 

191 def _report_from_import_violation( 

192 self, node: __.libcst.ImportFrom 

193 ) -> None: 

194 ''' Reports violation for from import statement. ''' 

195 # Extract module name 

196 if node.module is None: 

197 module_name = "relative import" 

198 elif isinstance( node.module, __.libcst.Attribute ): 

199 module_name = self._extract_dotted_name( node.module ) 

200 else: 

201 module_name = node.module.value 

202 # Extract imported names 

203 imported_names: list[ str ] = [ ] 

204 if isinstance( node.names, __.libcst.ImportStar ): 

205 imported_names = [ '*' ] 

206 else: 

207 for name in node.names: 

208 name_node = name.name 

209 if isinstance( name_node, __.libcst.Name ): 209 ↛ 207line 209 didn't jump to line 207 because the condition on line 209 was always true

210 imported_names.append( name_node.value ) 

211 names_str = ', '.join( imported_names ) 

212 message = ( 

213 f"Non-private import from '{module_name}': {names_str}. " 

214 f"Use private names (starting with _)." 

215 ) 

216 self._produce_violation( node, message, severity = 'warning' ) 

217 

218 def _extract_dotted_name( self, attr: __.libcst.Attribute ) -> str: 

219 ''' Extracts dotted module name from Attribute node. ''' 

220 parts: list[ str ] = [ ] 

221 current: __.libcst.BaseExpression = attr 

222 while isinstance( current, __.libcst.Attribute ): 

223 parts.append( current.attr.value ) 

224 current = current.value 

225 if isinstance( current, __.libcst.Name ): 225 ↛ 227line 225 didn't jump to line 227 because the condition on line 225 was always true

226 parts.append( current.value ) 

227 parts.reverse( ) 

228 return '.'.join( parts ) 

229 

230 

231# Self-register this rule 

232__.RULE_DESCRIPTORS[ 'VBL201' ] = __.RuleDescriptor( 

233 vbl_code = 'VBL201', 

234 descriptive_name = 'import-hub-enforcement', 

235 description = 'Enforces import hub pattern for non-hub modules.', 

236 category = 'imports', 

237 subcategory = 'architecture', 

238 rule_class = VBL201, 

239)