Coverage for sources / vibelinter / configuration.py: 81%
131 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-07 04:34 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-07 04:34 +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#============================================================================#
21''' Configuration file discovery and parsing. '''
24from tomli import loads as _toml_loads
26from . import __
27from . import exceptions as _exceptions
30PathLike: __.typx.TypeAlias = str | __.pathlib.Path
33class ConfigurationInvalidity( _exceptions.Omnierror, ValueError ):
34 ''' Configuration file invalidity. '''
36 def __init__( self, location: PathLike, reason: str ) -> None:
37 self.location = str( location )
38 self.reason = reason
39 super( ).__init__( f'Invalid configuration at {location}: {reason}' )
42class ConfigurationAbsence( _exceptions.Omnierror, FileNotFoundError ):
43 ''' Configuration file absence. '''
45 def __init__(
46 self,
47 location: __.Absential[ PathLike ] = __.absent,
48 ) -> None:
49 self.location = (
50 None if __.is_absent( location ) else str( location ) )
51 super( ).__init__(
52 "No pyproject.toml found in current or parent directories"
53 if __.is_absent( location )
54 else f"Configuration file not found: {location}" )
57class Configuration( __.immut.DataclassObject ):
58 ''' Linter configuration from pyproject.toml file.
60 Supports rule selection, file filtering, and context configuration.
61 '''
63 select: __.typx.Annotated[
64 __.Absential[ tuple[ str, ... ] ],
65 __.ddoc.Doc( 'VBL codes to enable (whitelist).' ) ] = __.absent
66 exclude_rules: __.typx.Annotated[
67 __.Absential[ tuple[ str, ... ] ],
68 __.ddoc.Doc( 'VBL codes to disable (blacklist).' ) ] = __.absent
69 include_paths: __.typx.Annotated[
70 __.Absential[ tuple[ str, ... ] ],
71 __.ddoc.Doc( 'File path patterns to include.' ) ] = __.absent
72 exclude_paths: __.typx.Annotated[
73 __.Absential[ tuple[ str, ... ] ],
74 __.ddoc.Doc( 'File path patterns to exclude.' ) ] = __.absent
75 context: __.typx.Annotated[
76 __.Absential[ int ],
77 __.ddoc.Doc( 'Number of context lines around violations.' )
78 ] = __.absent
79 rule_parameters: __.typx.Annotated[
80 __.immut.Dictionary[ str, __.immut.Dictionary[ str, __.typx.Any ] ],
81 __.ddoc.Doc( 'Per-rule configuration parameters.' )
82 ] = __.dcls.field( default_factory = lambda: __.immut.Dictionary( ) )
83 per_file_ignores: __.typx.Annotated[
84 __.immut.Dictionary[ str, tuple[ str, ... ] ],
85 __.ddoc.Doc( 'Per-file rule exclusions.' )
86 ] = __.dcls.field( default_factory = lambda: __.immut.Dictionary( ) )
89def discover_configuration(
90 start_directory: __.Absential[ PathLike ] = __.absent,
91) -> __.Absential[ Configuration ]:
92 ''' Discovers and loads configuration from pyproject.toml.
94 Searches from start directory up through parent directories.
95 Returns absent if no configuration file found.
96 '''
97 config_path = _discover_pyproject_toml( start_directory )
98 if __.is_absent( config_path ):
99 return __.absent
100 return load_configuration( config_path )
103def load_configuration( location: PathLike ) -> Configuration:
104 ''' Loads configuration from specified pyproject.toml file. '''
105 file_path = __.pathlib.Path( location )
106 try: content = file_path.read_text( encoding = 'utf-8' )
107 except ( OSError, IOError ) as exception:
108 raise ConfigurationAbsence( location ) from exception
109 try: data = _toml_loads( content )
110 except Exception as exception:
111 raise ConfigurationInvalidity(
112 location, f'Invalid TOML syntax: {exception}' ) from exception
113 try: tool_config = data.get( 'tool', { } ).get( 'vibelinter', { } )
114 except AttributeError as exception:
115 raise ConfigurationInvalidity(
116 location, 'Invalid TOML structure' ) from exception
117 return _parse_configuration( tool_config, location )
120def _discover_pyproject_toml(
121 start_directory: __.Absential[ PathLike ],
122) -> __.Absential[ __.pathlib.Path ]:
123 ''' Searches for pyproject.toml from start directory upward. '''
124 if __.is_absent( start_directory ):
125 current = __.pathlib.Path.cwd( )
126 else:
127 current = __.pathlib.Path( start_directory ).resolve( )
128 if current.is_file( ):
129 current = current.parent
130 while True:
131 candidate = current / 'pyproject.toml'
132 if candidate.exists( ) and candidate.is_file( ):
133 return candidate
134 parent = current.parent
135 if parent == current:
136 return __.absent
137 current = parent
140def _parse_configuration(
141 data: __.cabc.Mapping[ str, __.typx.Any ],
142 location: PathLike,
143) -> Configuration:
144 ''' Parses configuration dictionary from TOML data. '''
145 select = _parse_string_sequence( data, 'select', location )
146 exclude_rules = _parse_string_sequence( data, 'exclude', location )
147 include_paths = _parse_string_sequence( data, 'include', location )
148 exclude_paths = _parse_string_sequence(
149 data, 'exclude_paths', location )
150 context = _parse_optional_int( data, 'context', location )
151 rule_parameters = _parse_rule_parameters( data, location )
152 per_file_ignores = _parse_per_file_ignores( data, location )
153 return Configuration(
154 select = select,
155 exclude_rules = exclude_rules,
156 include_paths = include_paths,
157 exclude_paths = exclude_paths,
158 context = context,
159 rule_parameters = rule_parameters,
160 per_file_ignores = per_file_ignores,
161 )
164def _parse_optional_int(
165 data: __.cabc.Mapping[ str, __.typx.Any ],
166 key: str,
167 location: PathLike,
168) -> __.Absential[ int ]:
169 ''' Parses optional integer value from configuration. '''
170 if key not in data:
171 return __.absent
172 value = data[ key ]
173 if not isinstance( value, int ): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 typename = type( value ).__name__
175 raise ConfigurationInvalidity(
176 location, f'"{key}" must be an integer, got {typename}' )
177 if value < 0: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 raise ConfigurationInvalidity(
179 location, f'"{key}" must be non-negative, got {value}' )
180 return value
183def _parse_rule_parameters(
184 data: __.cabc.Mapping[ str, __.typx.Any ],
185 location: PathLike,
186) -> __.immut.Dictionary[ str, __.immut.Dictionary[ str, __.typx.Any ] ]:
187 ''' Parses per-rule configuration from [tool.vibelinter.rules.*]. '''
188 rules_section: __.typx.Any = data.get( 'rules', { } )
189 if not isinstance( rules_section, dict ): 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 typename = type( rules_section ).__name__
191 raise ConfigurationInvalidity(
192 location, f'"rules" must be a table, got {typename}' )
193 result: dict[ str, __.immut.Dictionary[ str, __.typx.Any ] ] = { }
194 section_dict = __.typx.cast(
195 dict[ __.typx.Any, __.typx.Any ], rules_section )
196 for rule_code, params in section_dict.items( ):
197 if not isinstance( rule_code, str ): 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 typename: str = type( rule_code ).__name__
199 raise ConfigurationInvalidity(
200 location, f'Rule code must be string, got {typename}' )
201 if not isinstance( params, dict ): 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 typename = type( params ).__name__
203 raise ConfigurationInvalidity(
204 location,
205 f'Rule "{rule_code}" parameters must be a table, '
206 f'got {typename}' )
207 param_dict = __.typx.cast( dict[ str, __.typx.Any ], params )
208 result[ rule_code ] = __.immut.Dictionary( param_dict )
209 return __.immut.Dictionary( result )
212def _parse_per_file_ignores(
213 data: __.cabc.Mapping[ str, __.typx.Any ],
214 location: PathLike,
215) -> __.immut.Dictionary[ str, tuple[ str, ... ] ]:
216 ''' Parses [tool.vibelinter.per-file-ignores]. '''
217 ignores_section: __.typx.Any = data.get( 'per-file-ignores', { } )
218 if not isinstance( ignores_section, dict ): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 typename = type( ignores_section ).__name__
220 raise ConfigurationInvalidity(
221 location, f'"per-file-ignores" must be a table, got {typename}' )
222 result: dict[ str, tuple[ str, ... ] ] = { }
223 section_dict = __.typx.cast(
224 dict[ __.typx.Any, __.typx.Any ], ignores_section )
225 for pattern, rules in section_dict.items( ):
226 if not isinstance( pattern, str ): 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 typename: str = type( pattern ).__name__
228 raise ConfigurationInvalidity(
229 location,
230 f'Per-file-ignores pattern must be string, got {typename}' )
231 if isinstance( rules, str ):
232 result[ pattern ] = ( rules, )
233 continue
234 if not isinstance( rules, list ): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 typename = type( rules ).__name__
236 raise ConfigurationInvalidity(
237 location,
238 f'Per-file-ignores rules for "{pattern}" must be list, '
239 f'got {typename}' )
240 rules_list = __.typx.cast( list[ __.typx.Any ], rules )
241 rule_list: list[ str ] = [ ]
242 for i, rule in enumerate( rules_list ):
243 if not isinstance( rule, str ): 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 typename = type( rule ).__name__
245 raise ConfigurationInvalidity(
246 location,
247 f'Rule in "{pattern}"[{i}] must be string, '
248 f'got {typename}' )
249 rule_list.append( rule )
250 result[ pattern ] = tuple( rule_list )
251 return __.immut.Dictionary( result )
254def _parse_string_sequence(
255 data: __.cabc.Mapping[ str, __.typx.Any ],
256 key: str,
257 location: PathLike,
258) -> __.Absential[ tuple[ str, ... ] ]:
259 ''' Parses optional list of strings from configuration. '''
260 if key not in data:
261 return __.absent
262 value: __.typx.Any = data[ key ]
263 if isinstance( value, str ): 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
264 return ( value, )
265 if not isinstance( value, list ): 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 typename = type( value ).__name__
267 raise ConfigurationInvalidity(
268 location,
269 f'"{key}" must be a string or list of strings, '
270 f'got {typename}' )
271 result: list[ str ] = [ ]
272 value_list = __.typx.cast( list[ __.typx.Any ], value )
273 for i, item in enumerate( value_list ):
274 if not isinstance( item, str ): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 typename: str = type( item ).__name__
276 raise ConfigurationInvalidity(
277 location,
278 f'"{key}"[{i}] must be a string, got {typename}' )
279 result.append( item )
280 return tuple( result )