Coverage for sources / vibelinter / configuration.py: 84%

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''' Configuration file discovery and parsing. ''' 

22 

23 

24from tomli import loads as _toml_loads 

25 

26from . import __ 

27from . import exceptions as _exceptions 

28 

29 

30PathLike: __.typx.TypeAlias = str | __.pathlib.Path 

31 

32 

33class ConfigurationInvalidity( _exceptions.Omnierror, ValueError ): 

34 ''' Configuration file invalidity. ''' 

35 

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}' ) 

40 

41 

42class ConfigurationAbsence( _exceptions.Omnierror, FileNotFoundError ): 

43 ''' Configuration file absence. ''' 

44 

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 ) 

56 

57 

58class Configuration( __.immut.DataclassObject ): 

59 ''' Linter configuration from pyproject.toml file. 

60 

61 Supports rule selection, file filtering, and context configuration. 

62 ''' 

63 

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( ) ) 

84 

85 

86def discover_configuration( 

87 start_directory: __.Absential[ PathLike ] = __.absent, 

88) -> __.Absential[ Configuration ]: 

89 ''' Discovers and loads configuration from pyproject.toml. 

90 

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 ) 

98 

99 

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 ) 

115 

116 

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 

135 

136 

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 ) 

157 

158 

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 

176 

177 

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 ) 

205 

206 

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 )