Coverage for src/docstring_format_checker/config.py: 100%

112 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-10 13:31 +0000

1# ============================================================================ # 

2# # 

3# Title: Configuration Management # 

4# Purpose: Configuration for docstring format checking # 

5# Notes: Notes # 

6# Author: chrimaho # 

7# Created: Created # 

8# References: References # 

9# Sources: Sources # 

10# Edited: Edited # 

11# # 

12# ============================================================================ # 

13 

14 

15# ---------------------------------------------------------------------------- # 

16# # 

17# Overview #### 

18# # 

19# ---------------------------------------------------------------------------- # 

20 

21 

22# ---------------------------------------------------------------------------- # 

23# Description #### 

24# ---------------------------------------------------------------------------- # 

25 

26 

27""" 

28!!! note "Summary" 

29 Configuration handling for the docstring format checker. 

30""" 

31 

32 

33# ---------------------------------------------------------------------------- # 

34# # 

35# Setup #### 

36# # 

37# ---------------------------------------------------------------------------- # 

38 

39 

40## --------------------------------------------------------------------------- # 

41## Imports #### 

42## --------------------------------------------------------------------------- # 

43 

44 

45# ## Python StdLib Imports ---- 

46import sys 

47from dataclasses import dataclass 

48from pathlib import Path 

49from typing import Any, Literal, Optional, Union 

50 

51# ## Local First Party Imports ---- 

52from docstring_format_checker.utils.exceptions import ( 

53 InvalidConfigError, 

54 InvalidConfigError_DuplicateOrderValues, 

55 InvalidTypeValuesError, 

56) 

57 

58 

59if sys.version_info >= (3, 11): 

60 # ## Python StdLib Imports ---- 

61 import tomllib 

62else: 

63 # ## Python Third Party Imports ---- 

64 import tomli as tomllib 

65 

66 

67## --------------------------------------------------------------------------- # 

68## Exports #### 

69## --------------------------------------------------------------------------- # 

70 

71 

72__all__: list[str] = [ 

73 "GlobalConfig", 

74 "SectionConfig", 

75 "Config", 

76 "DEFAULT_CONFIG", 

77 "load_config", 

78 "find_config_file", 

79] 

80 

81 

82## --------------------------------------------------------------------------- # 

83## Constants #### 

84## --------------------------------------------------------------------------- # 

85 

86 

87VALID_TYPES: tuple[str, ...] = ( 

88 "free_text", # Free text sections (summary, details, examples, notes) 

89 "list_name", # Simple name sections (name) 

90 "list_type", # Simple type sections (raises, yields) 

91 "list_name_and_type", # Params-style sections (name (type): description) 

92) 

93 

94 

95# ---------------------------------------------------------------------------- # 

96# # 

97# Config #### 

98# # 

99# ---------------------------------------------------------------------------- # 

100 

101 

102## --------------------------------------------------------------------------- # 

103## GlobalConfig #### 

104## --------------------------------------------------------------------------- # 

105 

106 

107@dataclass 

108class GlobalConfig: 

109 """ 

110 !!! note "Summary" 

111 Global configuration for docstring checking behavior. 

112 """ 

113 

114 allow_undefined_sections: bool = False 

115 require_docstrings: bool = True 

116 check_private: bool = False 

117 

118 

119## --------------------------------------------------------------------------- # 

120## SectionConfig #### 

121## --------------------------------------------------------------------------- # 

122 

123 

124@dataclass 

125class SectionConfig: 

126 """ 

127 !!! note "Summary" 

128 Configuration for a docstring section. 

129 """ 

130 

131 order: int 

132 name: str 

133 type: Literal["free_text", "list_name", "list_type", "list_name_and_type"] 

134 admonition: Union[bool, str] = False 

135 prefix: str = "" # Support any prefix string 

136 required: bool = False 

137 message: str = "" # Optional message for validation errors 

138 

139 def __post_init__(self) -> None: 

140 """ 

141 !!! note "Summary" 

142 Validate configuration after initialization. 

143 """ 

144 self._validate_types() 

145 self._validate_admonition_prefix_combination() 

146 

147 def _validate_types(self) -> None: 

148 """ 

149 !!! note "Summary" 

150 Validate the 'type' field. 

151 """ 

152 if self.type not in VALID_TYPES: 

153 raise InvalidTypeValuesError(f"Invalid section type: {self.type}. Valid types: {VALID_TYPES}") 

154 

155 def _validate_admonition_prefix_combination(self) -> None: 

156 """ 

157 !!! note "Summary" 

158 Validate admonition and prefix combination rules. 

159 """ 

160 

161 if isinstance(self.admonition, bool): 

162 # Rule: admonition cannot be True (only False or string) 

163 if self.admonition is True: 

164 raise ValueError(f"Section '{self.name}': admonition cannot be True, must be False or a string") 

165 

166 # Rule: if admonition is False, prefix cannot be provided 

167 if self.admonition is False and self.prefix: 

168 raise ValueError(f"Section '{self.name}': when admonition=False, prefix cannot be provided") 

169 

170 elif isinstance(self.admonition, str): 

171 # Rule: if admonition is a string, prefix must be provided 

172 if not self.prefix: 

173 raise ValueError(f"Section '{self.name}': when admonition is a string, prefix must be provided") 

174 

175 else: 

176 raise ValueError( 

177 f"Section '{self.name}': admonition must be a boolean or string, got {type(self.admonition)}" 

178 ) 

179 

180 

181## --------------------------------------------------------------------------- # 

182## Validations #### 

183## --------------------------------------------------------------------------- # 

184 

185 

186def _validate_config_order(config_sections: list[SectionConfig]) -> None: 

187 """ 

188 !!! note "Summary" 

189 Validate that section order values are unique. 

190 

191 Params: 

192 config_sections (list[SectionConfig]): 

193 List of section configurations to validate. 

194 

195 Raises: 

196 (InvalidConfigError_DuplicateOrderValues): 

197 If duplicate order values are found. 

198 

199 Returns: 

200 (None): 

201 Nothing is returned. 

202 """ 

203 

204 # Validate no duplicate order values 

205 order_values: list[int] = [section.order for section in config_sections] 

206 seen_orders: set[int] = set() 

207 duplicate_orders: set[int] = set() 

208 

209 for order in order_values: 

210 if order in seen_orders: 

211 duplicate_orders.add(order) 

212 else: 

213 seen_orders.add(order) 

214 

215 if duplicate_orders: 

216 raise InvalidConfigError_DuplicateOrderValues( 

217 f"Configuration contains duplicate order values: {sorted(duplicate_orders)}. " 

218 "Each section must have a unique order value." 

219 ) 

220 

221 

222# ---------------------------------------------------------------------------- # 

223# # 

224# Config Container #### 

225# # 

226# ---------------------------------------------------------------------------- # 

227 

228 

229@dataclass 

230class Config: 

231 """ 

232 !!! note "Summary" 

233 Complete configuration containing global settings and section definitions. 

234 """ 

235 

236 global_config: GlobalConfig 

237 sections: list[SectionConfig] 

238 

239 

240# ---------------------------------------------------------------------------- # 

241# # 

242# Default Configuration #### 

243# # 

244# ---------------------------------------------------------------------------- # 

245 

246 

247DEFAULT_SECTIONS: list[SectionConfig] = [ 

248 SectionConfig( 

249 order=1, 

250 name="summary", 

251 type="free_text", 

252 admonition="note", 

253 prefix="!!!", 

254 required=True, 

255 ), 

256 SectionConfig( 

257 order=2, 

258 name="details", 

259 type="free_text", 

260 admonition="info", 

261 prefix="???+", 

262 required=False, 

263 ), 

264 SectionConfig( 

265 order=3, 

266 name="params", 

267 type="list_name_and_type", 

268 required=True, 

269 ), 

270 SectionConfig( 

271 order=4, 

272 name="returns", 

273 type="list_name_and_type", 

274 required=False, 

275 ), 

276 SectionConfig( 

277 order=5, 

278 name="yields", 

279 type="list_type", 

280 required=False, 

281 ), 

282 SectionConfig( 

283 order=6, 

284 name="raises", 

285 type="list_type", 

286 required=False, 

287 ), 

288 SectionConfig( 

289 order=7, 

290 name="examples", 

291 type="free_text", 

292 admonition="example", 

293 prefix="???+", 

294 required=False, 

295 ), 

296 SectionConfig( 

297 order=8, 

298 name="notes", 

299 type="free_text", 

300 admonition="note", 

301 prefix="???", 

302 required=False, 

303 ), 

304] 

305 

306 

307DEFAULT_CONFIG: Config = Config( 

308 global_config=GlobalConfig(), 

309 sections=DEFAULT_SECTIONS, 

310) 

311 

312 

313def load_config(config_path: Optional[Union[str, Path]] = None) -> Config: 

314 """ 

315 !!! note "Summary" 

316 Load configuration from a TOML file or return default configuration. 

317 

318 Params: 

319 config_path (Optional[Union[str, Path]]): 

320 Path to the TOML configuration file. 

321 If `None`, looks for `pyproject.toml` in current directory. 

322 Default: `None`. 

323 

324 Raises: 

325 (FileNotFoundError): 

326 If the specified config file doesn't exist. 

327 (InvalidConfigError): 

328 If the configuration is invalid. 

329 

330 Returns: 

331 (Config): 

332 Configuration object containing global settings and section definitions. 

333 """ 

334 

335 if config_path is None: 

336 # Look for pyproject.toml in current directory 

337 pyproject_path: Path = Path.cwd().joinpath("pyproject.toml") 

338 if pyproject_path.exists(): 

339 config_path = pyproject_path 

340 else: 

341 return DEFAULT_CONFIG 

342 

343 # Convert to Path object and check existence 

344 config_path = Path(config_path) 

345 if not config_path.exists(): 

346 raise FileNotFoundError(f"Configuration file not found: {config_path}") 

347 

348 try: 

349 with open(config_path, "rb") as f: 

350 config_data: dict[str, Any] = tomllib.load(f) 

351 except Exception as e: 

352 raise InvalidConfigError(f"Failed to parse TOML file {config_path}: {e}") from e 

353 

354 # Try to find configuration under [tool.dfc] or [tool.docstring-format-checker] 

355 tool_config = None 

356 if "tool" in config_data: 

357 if "dfc" in config_data["tool"]: 

358 tool_config = config_data["tool"]["dfc"] 

359 elif "docstring-format-checker" in config_data["tool"]: 

360 tool_config = config_data["tool"]["docstring-format-checker"] 

361 

362 if tool_config is None: 

363 return DEFAULT_CONFIG 

364 

365 # Parse global configuration flags 

366 global_config = GlobalConfig( 

367 allow_undefined_sections=tool_config.get("allow_undefined_sections", False), 

368 require_docstrings=tool_config.get("require_docstrings", True), 

369 check_private=tool_config.get("check_private", False), 

370 ) 

371 

372 # Parse sections configuration 

373 sections_config: list[SectionConfig] = [] 

374 if "sections" in tool_config: 

375 sections_data = tool_config["sections"] 

376 for section_data in sections_data: 

377 try: 

378 # Get admonition value with proper default handling 

379 admonition_value: Union[str, bool] = section_data.get("admonition") 

380 if admonition_value is None: 

381 admonition_value = False # Use SectionConfig default 

382 

383 section = SectionConfig( 

384 order=section_data.get("order", 0), 

385 name=section_data.get("name", ""), 

386 type=section_data.get("type", ""), 

387 admonition=admonition_value, 

388 prefix=section_data.get("prefix", ""), 

389 required=section_data.get("required", False), 

390 ) 

391 sections_config.append(section) 

392 except (KeyError, TypeError, ValueError, InvalidTypeValuesError) as e: 

393 raise InvalidConfigError(f"Invalid section configuration: {section_data}. Error: {e}") from e 

394 

395 # Use default sections if none provided, otherwise validate and sort 

396 if not sections_config: 

397 sections_config = DEFAULT_SECTIONS 

398 else: 

399 # Validate no duplicate order values 

400 _validate_config_order(config_sections=sections_config) 

401 

402 # Sort by order 

403 sections_config.sort(key=lambda x: x.order) 

404 

405 return Config(global_config=global_config, sections=sections_config) 

406 

407 

408def find_config_file(start_path: Optional[Path] = None) -> Optional[Path]: 

409 """ 

410 !!! note "Summary" 

411 Find configuration file by searching up the directory tree. 

412 

413 Params: 

414 start_path (Optional[Path]): 

415 Directory to start searching from. 

416 If `None`, resolves to current directory. 

417 Default: `None`. 

418 

419 Returns: 

420 (Optional[Path]): 

421 Path to the configuration file if found, None otherwise. 

422 """ 

423 if start_path is None: 

424 start_path = Path.cwd() 

425 

426 current_path: Path = start_path.resolve() 

427 

428 while current_path != current_path.parent: 

429 pyproject_path: Path = current_path.joinpath("pyproject.toml") 

430 if pyproject_path.exists(): 

431 # Check if it contains dfc configuration 

432 try: 

433 with open(pyproject_path, "rb") as f: 

434 config_data: dict[str, Any] = tomllib.load(f) 

435 if "tool" in config_data and ( 

436 "dfc" in config_data["tool"] or "docstring-format-checker" in config_data["tool"] 

437 ): 

438 return pyproject_path 

439 except Exception: 

440 pass 

441 

442 current_path = current_path.parent 

443 

444 return None