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

106 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 00:00 +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 # Check if all imported names are private 

89 if all( self._is_alias_private( alias ) for alias in node.names ): 

90 return True 

91 self._simple_imports.append( node ) 

92 return True 

93 

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

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

96 if self._is_hub_module: 

97 return True 

98 # Allow imports inside function bodies (local imports) 

99 if self._function_depth > 0: 

100 return True 

101 if self._is_future_import( node ): 

102 return True 

103 if self._has_private_names( node ): 

104 return True 

105 self._from_imports.append( node ) 

106 return True 

107 

108 def _analyze_collections( self ) -> None: 

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

110 for node in self._simple_imports: 

111 self._report_simple_import_violation( node ) 

112 for node in self._from_imports: 

113 self._report_from_import_violation( node ) 

114 

115 def _is_import_hub_module( self ) -> bool: 

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

117 

118 Uses glob patterns from configuration to identify hub modules. 

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

120 ''' 

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

122 for pattern in self._hub_patterns: 

123 # Try matching against the file path 

124 if file_path.match( pattern ): 

125 return True 

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

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

128 return True 

129 return False 

130 

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

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

133 module = node.module 

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

135 return False 

136 if module is None: 

137 return False 

138 return module.value == '__future__' 

139 

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

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

142 

143 Examples of allowed imports: 

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

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

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

147 

148 Examples of violations: 

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

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

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

152 ''' 

153 # Star imports are never private 

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

155 return False 

156 # Check each imported name 

157 return all( self._is_alias_private( alias ) for alias in node.names ) 

158 

159 def _is_alias_private( self, alias: __.libcst.ImportAlias ) -> bool: 

160 ''' Checks if an import alias results in a private name. ''' 

161 if isinstance( alias.asname, __.libcst.AsName ): 

162 alias_name = alias.asname.name 

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

164 return alias_name.value.startswith( '_' ) 

165 return False 

166 node = alias.name 

167 while isinstance( node, __.libcst.Attribute ): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 node = node.value 

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

170 return node.value.startswith( '_' ) 

171 return False 

172 

173 def _report_simple_import_violation( 

174 self, node: __.libcst.Import 

175 ) -> None: 

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

177 # Extract module name from import 

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

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

180 message = ( 

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

182 f"Use import hub or private alias." 

183 ) 

184 else: 

185 message = ( 

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

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

188 

189 def _report_from_import_violation( 

190 self, node: __.libcst.ImportFrom 

191 ) -> None: 

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

193 # Extract module name 

194 if node.module is None: 

195 module_name = "relative import" 

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

197 module_name = self._extract_dotted_name( node.module ) 

198 else: 

199 module_name = node.module.value 

200 # Extract imported names 

201 imported_names: list[ str ] = [ ] 

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

203 imported_names = [ '*' ] 

204 else: 

205 for name in node.names: 

206 name_node = name.name 

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

208 imported_names.append( name_node.value ) 

209 names_str = ', '.join( imported_names ) 

210 message = ( 

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

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

213 ) 

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

215 

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

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

218 parts: list[ str ] = [ ] 

219 current: __.libcst.BaseExpression = attr 

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

221 parts.append( current.attr.value ) 

222 current = current.value 

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

224 parts.append( current.value ) 

225 parts.reverse( ) 

226 return '.'.join( parts ) 

227 

228 

229# Self-register this rule 

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

231 vbl_code = 'VBL201', 

232 descriptive_name = 'import-hub-enforcement', 

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

234 category = 'imports', 

235 subcategory = 'architecture', 

236 rule_class = VBL201, 

237)