Coverage for sources / vibelinter / configuration.py: 84%
104 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 00:00 +0000
« 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 -*-
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 message = (
52 'No pyproject.toml found in current or parent directories'
53 if __.is_absent( location )
54 else f'Configuration file not found: {location}' )
55 super( ).__init__( message )
58class Configuration( __.immut.DataclassObject ):
59 ''' Linter configuration from pyproject.toml file.
61 Supports rule selection, file filtering, and context configuration.
62 '''
64 select: __.typx.Annotated[
65 __.Absential[ tuple[ str, ... ] ],
66 __.ddoc.Doc( 'VBL codes to enable (whitelist).' ) ] = __.absent
67 exclude_rules: __.typx.Annotated[
68 __.Absential[ tuple[ str, ... ] ],
69 __.ddoc.Doc( 'VBL codes to disable (blacklist).' ) ] = __.absent
70 include_paths: __.typx.Annotated[
71 __.Absential[ tuple[ str, ... ] ],
72 __.ddoc.Doc( 'File path patterns to include.' ) ] = __.absent
73 exclude_paths: __.typx.Annotated[
74 __.Absential[ tuple[ str, ... ] ],
75 __.ddoc.Doc( 'File path patterns to exclude.' ) ] = __.absent
76 context: __.typx.Annotated[
77 __.Absential[ int ],
78 __.ddoc.Doc( 'Number of context lines around violations.' )
79 ] = __.absent
80 rule_parameters: __.typx.Annotated[
81 __.immut.Dictionary[ str, __.immut.Dictionary[ str, __.typx.Any ] ],
82 __.ddoc.Doc( 'Per-rule configuration parameters.' )
83 ] = __.dcls.field( default_factory = lambda: __.immut.Dictionary( ) )
86def discover_configuration(
87 start_directory: __.Absential[ PathLike ] = __.absent,
88) -> __.Absential[ Configuration ]:
89 ''' Discovers and loads configuration from pyproject.toml.
91 Searches from start directory up through parent directories.
92 Returns absent if no configuration file found.
93 '''
94 config_path = _discover_pyproject_toml( start_directory )
95 if __.is_absent( config_path ):
96 return __.absent
97 return load_configuration( config_path )
100def load_configuration( location: PathLike ) -> Configuration:
101 ''' Loads configuration from specified pyproject.toml file. '''
102 file_path = __.pathlib.Path( location )
103 try: content = file_path.read_text( encoding = 'utf-8' )
104 except ( OSError, IOError ) as exception:
105 raise ConfigurationAbsence( location ) from exception
106 try: data = _toml_loads( content )
107 except Exception as exception:
108 raise ConfigurationInvalidity(
109 location, f'Invalid TOML syntax: {exception}' ) from exception
110 try: tool_config = data.get( 'tool', { } ).get( 'vibelinter', { } )
111 except AttributeError as exception:
112 raise ConfigurationInvalidity(
113 location, 'Invalid TOML structure' ) from exception
114 return _parse_configuration( tool_config, location )
117def _discover_pyproject_toml(
118 start_directory: __.Absential[ PathLike ],
119) -> __.Absential[ __.pathlib.Path ]:
120 ''' Searches for pyproject.toml from start directory upward. '''
121 if __.is_absent( start_directory ):
122 current = __.pathlib.Path.cwd( )
123 else:
124 current = __.pathlib.Path( start_directory ).resolve( )
125 if current.is_file( ):
126 current = current.parent
127 while True:
128 candidate = current / 'pyproject.toml'
129 if candidate.exists( ) and candidate.is_file( ):
130 return candidate
131 parent = current.parent
132 if parent == current:
133 return __.absent
134 current = parent
137def _parse_configuration(
138 data: __.cabc.Mapping[ str, __.typx.Any ],
139 location: PathLike,
140) -> Configuration:
141 ''' Parses configuration dictionary from TOML data. '''
142 select = _parse_string_sequence( data, 'select', location )
143 exclude_rules = _parse_string_sequence( data, 'exclude', location )
144 include_paths = _parse_string_sequence( data, 'include', location )
145 exclude_paths = _parse_string_sequence(
146 data, 'exclude_paths', location )
147 context = _parse_optional_int( data, 'context', location )
148 rule_parameters = _parse_rule_parameters( data, location )
149 return Configuration(
150 select = select,
151 exclude_rules = exclude_rules,
152 include_paths = include_paths,
153 exclude_paths = exclude_paths,
154 context = context,
155 rule_parameters = rule_parameters,
156 )
159def _parse_optional_int(
160 data: __.cabc.Mapping[ str, __.typx.Any ],
161 key: str,
162 location: PathLike,
163) -> __.Absential[ int ]:
164 ''' Parses optional integer value from configuration. '''
165 if key not in data:
166 return __.absent
167 value = data[ key ]
168 if not isinstance( value, int ): 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 typename = type( value ).__name__
170 raise ConfigurationInvalidity(
171 location, f'"{key}" must be an integer, got {typename}' )
172 if value < 0: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 raise ConfigurationInvalidity(
174 location, f'"{key}" must be non-negative, got {value}' )
175 return value
178def _parse_rule_parameters(
179 data: __.cabc.Mapping[ str, __.typx.Any ],
180 location: PathLike,
181) -> __.immut.Dictionary[ str, __.immut.Dictionary[ str, __.typx.Any ] ]:
182 ''' Parses per-rule configuration from [tool.vibelinter.rules.*]. '''
183 rules_section: __.typx.Any = data.get( 'rules', { } )
184 if not isinstance( rules_section, dict ): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 typename = type( rules_section ).__name__
186 raise ConfigurationInvalidity(
187 location, f'"rules" must be a table, got {typename}' )
188 result: dict[ str, __.immut.Dictionary[ str, __.typx.Any ] ] = { }
189 section_dict = __.typx.cast(
190 dict[ __.typx.Any, __.typx.Any ], rules_section )
191 for rule_code, params in section_dict.items( ):
192 if not isinstance( rule_code, str ): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 typename: str = type( rule_code ).__name__
194 raise ConfigurationInvalidity(
195 location, f'Rule code must be string, got {typename}' )
196 if not isinstance( params, dict ): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 typename = type( params ).__name__
198 raise ConfigurationInvalidity(
199 location,
200 f'Rule "{rule_code}" parameters must be a table, '
201 f'got {typename}' )
202 param_dict = __.typx.cast( dict[ str, __.typx.Any ], params )
203 result[ rule_code ] = __.immut.Dictionary( param_dict )
204 return __.immut.Dictionary( result )
207def _parse_string_sequence(
208 data: __.cabc.Mapping[ str, __.typx.Any ],
209 key: str,
210 location: PathLike,
211) -> __.Absential[ tuple[ str, ... ] ]:
212 ''' Parses optional list of strings from configuration. '''
213 if key not in data:
214 return __.absent
215 value: __.typx.Any = data[ key ]
216 if isinstance( value, str ): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 return ( value, )
218 if not isinstance( value, list ): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 typename = type( value ).__name__
220 raise ConfigurationInvalidity(
221 location,
222 f'"{key}" must be a string or list of strings, '
223 f'got {typename}' )
224 result: list[ str ] = [ ]
225 value_list = __.typx.cast( list[ __.typx.Any ], value )
226 for i, item in enumerate( value_list ):
227 if not isinstance( item, str ): 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 typename: str = type( item ).__name__
229 raise ConfigurationInvalidity(
230 location,
231 f'"{key}"[{i}] must be a string, got {typename}' )
232 result.append( item )
233 return tuple( result )