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

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

55 

56 

57class Configuration( __.immut.DataclassObject ): 

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

59 

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

61 ''' 

62 

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

87 

88 

89def discover_configuration( 

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

91) -> __.Absential[ Configuration ]: 

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

93 

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 ) 

101 

102 

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 ) 

118 

119 

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 

138 

139 

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 ) 

162 

163 

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 

181 

182 

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 ) 

210 

211 

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 ) 

252 

253 

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 )