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
« 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 -*-
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#============================================================================#
22''' VBL201: Import hub enforcement - no public imports in non-hub modules.
26 Category: Imports / Architecture
27 Subcategory: Namespace Management
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)
35 This maintains architectural consistency, prevents namespace pollution,
36 and makes the codebase self-documenting.
37'''
40from . import __
43class VBL201( __.BaseRule ):
44 ''' Enforces import hub pattern for non-hub modules. '''
46 @property
47 def rule_id( self ) -> str:
48 return 'VBL201'
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 ] = [ ]
70 def visit_FunctionDef( self, node: __.libcst.FunctionDef ) -> bool:
71 ''' Tracks entry into function definitions. '''
72 self._function_depth += 1
73 return True
75 def leave_FunctionDef(
76 self, original_node: __.libcst.FunctionDef
77 ) -> None:
78 ''' Tracks exit from function definitions. '''
79 self._function_depth -= 1
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
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
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 )
112 def _is_import_hub_module( self ) -> bool:
113 ''' Checks if current file matches any hub module pattern.
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
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__'
137 def _has_private_names( self, node: __.libcst.ImportFrom ) -> bool:
138 ''' Checks if all imported names are private (start with _).
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 _)
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
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' )
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' )
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 )
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)