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

133 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-25 08:09 +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, field 

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 = field( 

115 default=False, 

116 metadata={ 

117 "title": "Allow Undefined Sections", 

118 "description": "Allow sections not defined in the configuration.", 

119 }, 

120 ) 

121 require_docstrings: bool = field( 

122 default=True, 

123 metadata={ 

124 "title": "Require Docstrings", 

125 "description": "Require docstrings for all functions/methods.", 

126 }, 

127 ) 

128 check_private: bool = field( 

129 default=False, 

130 metadata={ 

131 "title": "Check Private Members", 

132 "description": "Check docstrings for private members (starting with an underscore).", 

133 }, 

134 ) 

135 validate_param_types: bool = field( 

136 default=True, 

137 metadata={ 

138 "title": "Validate Parameter Types", 

139 "description": "Validate that parameter types are provided in the docstring.", 

140 }, 

141 ) 

142 optional_style: Literal["silent", "validate", "strict"] = field( 

143 default="validate", 

144 metadata={ 

145 "title": "Optional Style", 

146 "description": "The style for reporting issues in optional sections.", 

147 }, 

148 ) 

149 

150 

151## --------------------------------------------------------------------------- # 

152## SectionConfig #### 

153## --------------------------------------------------------------------------- # 

154 

155 

156@dataclass 

157class SectionConfig: 

158 """ 

159 !!! note "Summary" 

160 Configuration for a docstring section. 

161 """ 

162 

163 name: str = field( 

164 metadata={ 

165 "title": "Name", 

166 "description": "Name of the docstring section.", 

167 }, 

168 ) 

169 type: Literal["free_text", "list_name", "list_type", "list_name_and_type"] = field( 

170 metadata={ 

171 "title": "Type", 

172 "description": "Type of the section content.", 

173 }, 

174 ) 

175 order: Optional[int] = field( 

176 default=None, 

177 metadata={ 

178 "title": "Order", 

179 "description": "Order of the section in the docstring.", 

180 }, 

181 ) 

182 admonition: Union[bool, str] = field( 

183 default=False, 

184 metadata={ 

185 "title": "Admonition", 

186 "description": "Admonition style for the section. Can be False (no admonition) or a string specifying the admonition type.", 

187 }, 

188 ) 

189 prefix: str = field( 

190 default="", 

191 metadata={ 

192 "title": "Prefix", 

193 "description": "Prefix string for the admonition values.", 

194 }, 

195 ) 

196 required: bool = field( 

197 default=False, 

198 metadata={ 

199 "title": "Required", 

200 "description": "Whether this section is required in the docstring.", 

201 }, 

202 ) 

203 message: str = field( 

204 default="", 

205 metadata={ 

206 "title": "Message", 

207 "description": "Optional message for validation errors.", 

208 }, 

209 ) 

210 

211 def __post_init__(self) -> None: 

212 """ 

213 !!! note "Summary" 

214 Validate configuration after initialization. 

215 """ 

216 self._validate_types() 

217 self._validate_admonition_prefix_combination() 

218 

219 def _validate_types(self) -> None: 

220 """ 

221 !!! note "Summary" 

222 Validate the 'type' field. 

223 """ 

224 if self.type not in VALID_TYPES: 

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

226 

227 def _validate_admonition_prefix_combination(self) -> None: 

228 """ 

229 !!! note "Summary" 

230 Validate admonition and prefix combination rules. 

231 """ 

232 

233 if isinstance(self.admonition, bool): 

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

235 if self.admonition is True: 

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

237 

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

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

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

241 

242 elif isinstance(self.admonition, str): 

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

244 if not self.prefix: 

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

246 

247 else: 

248 raise ValueError( 

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

250 ) 

251 

252 

253## --------------------------------------------------------------------------- # 

254## Validations #### 

255## --------------------------------------------------------------------------- # 

256 

257 

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

259 """ 

260 !!! note "Summary" 

261 Validate that section order values are unique. 

262 

263 Params: 

264 config_sections (list[SectionConfig]): 

265 List of section configurations to validate. 

266 

267 Raises: 

268 (InvalidConfigError_DuplicateOrderValues): 

269 If duplicate order values are found. 

270 

271 Returns: 

272 (None): 

273 Nothing is returned. 

274 """ 

275 

276 # Validate no duplicate order values 

277 order_values: list[int] = [section.order for section in config_sections if section.order is not None] 

278 seen_orders: set[int] = set() 

279 duplicate_orders: set[int] = set() 

280 

281 for order in order_values: 

282 if order in seen_orders: 

283 duplicate_orders.add(order) 

284 else: 

285 seen_orders.add(order) 

286 

287 if duplicate_orders: 

288 raise InvalidConfigError_DuplicateOrderValues( 

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

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

291 ) 

292 

293 

294# ---------------------------------------------------------------------------- # 

295# # 

296# Config Container #### 

297# # 

298# ---------------------------------------------------------------------------- # 

299 

300 

301@dataclass 

302class Config: 

303 """ 

304 !!! note "Summary" 

305 Complete configuration containing global settings and section definitions. 

306 """ 

307 

308 global_config: GlobalConfig 

309 sections: list[SectionConfig] 

310 

311 

312# ---------------------------------------------------------------------------- # 

313# # 

314# Default Configuration #### 

315# # 

316# ---------------------------------------------------------------------------- # 

317 

318 

319DEFAULT_SECTIONS: list[SectionConfig] = [ 

320 SectionConfig( 

321 order=1, 

322 name="summary", 

323 type="free_text", 

324 admonition="note", 

325 prefix="!!!", 

326 required=True, 

327 ), 

328 SectionConfig( 

329 order=2, 

330 name="details", 

331 type="free_text", 

332 admonition="info", 

333 prefix="???+", 

334 required=False, 

335 ), 

336 SectionConfig( 

337 order=3, 

338 name="params", 

339 type="list_name_and_type", 

340 required=True, 

341 ), 

342 SectionConfig( 

343 order=4, 

344 name="returns", 

345 type="list_name_and_type", 

346 required=False, 

347 ), 

348 SectionConfig( 

349 order=5, 

350 name="yields", 

351 type="list_type", 

352 required=False, 

353 ), 

354 SectionConfig( 

355 order=6, 

356 name="raises", 

357 type="list_type", 

358 required=False, 

359 ), 

360 SectionConfig( 

361 order=7, 

362 name="examples", 

363 type="free_text", 

364 admonition="example", 

365 prefix="???+", 

366 required=False, 

367 ), 

368 SectionConfig( 

369 order=8, 

370 name="notes", 

371 type="free_text", 

372 admonition="note", 

373 prefix="???", 

374 required=False, 

375 ), 

376] 

377 

378 

379DEFAULT_CONFIG: Config = Config( 

380 global_config=GlobalConfig(), 

381 sections=DEFAULT_SECTIONS, 

382) 

383 

384 

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

386 """ 

387 !!! note "Summary" 

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

389 

390 Params: 

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

392 Path to the TOML configuration file. 

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

394 Default: `None`. 

395 

396 Raises: 

397 (FileNotFoundError): 

398 If the specified config file doesn't exist. 

399 (InvalidConfigError): 

400 If the configuration is invalid. 

401 

402 Returns: 

403 (Config): 

404 Configuration object containing global settings and section definitions. 

405 """ 

406 # Resolve config file path 

407 resolved_path = _resolve_config_path(config_path) 

408 if resolved_path is None: 

409 return DEFAULT_CONFIG 

410 

411 # Parse TOML configuration 

412 config_data = _parse_toml_file(resolved_path) 

413 

414 # Extract tool configuration 

415 tool_config = _extract_tool_config(config_data) 

416 if tool_config is None: 

417 return DEFAULT_CONFIG 

418 

419 # Parse configuration components 

420 global_config = _parse_global_config(tool_config) 

421 sections_config = _parse_sections_config(tool_config) 

422 

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

424 

425 

426def _resolve_config_path(config_path: Optional[Union[str, Path]]) -> Optional[Path]: 

427 """ 

428 !!! note "Summary" 

429 Resolve configuration file path. 

430 

431 Params: 

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

433 Optional path to configuration file. 

434 

435 Raises: 

436 (FileNotFoundError): 

437 If specified config file does not exist. 

438 

439 Returns: 

440 (Optional[Path]): 

441 Resolved Path object or None if no config found. 

442 """ 

443 if config_path is None: 

444 # Look for pyproject.toml in current directory 

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

446 if pyproject_path.exists(): 

447 return pyproject_path 

448 else: 

449 return None 

450 

451 # Convert to Path object and check existence 

452 config_path = Path(config_path) 

453 if not config_path.exists(): 

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

455 

456 return config_path 

457 

458 

459def _parse_toml_file(config_path: Path) -> dict[str, Any]: 

460 """ 

461 !!! note "Summary" 

462 Parse TOML configuration file. 

463 

464 Params: 

465 config_path (Path): 

466 Path to TOML file to parse. 

467 

468 Raises: 

469 (InvalidConfigError): 

470 If TOML parsing fails. 

471 

472 Returns: 

473 (dict[str, Any]): 

474 Parsed TOML data as dictionary. 

475 """ 

476 try: 

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

478 return tomllib.load(f) 

479 except Exception as e: 

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

481 

482 

483def _extract_tool_config(config_data: dict[str, Any]) -> Optional[dict[str, Any]]: 

484 """ 

485 !!! note "Summary" 

486 Extract tool configuration from TOML data. 

487 

488 Params: 

489 config_data (dict[str, Any]): 

490 Parsed TOML data dictionary. 

491 

492 Returns: 

493 (Optional[dict[str, Any]]): 

494 Tool configuration dictionary or None if not found. 

495 """ 

496 if "tool" not in config_data: 

497 return None 

498 

499 tool_section = config_data["tool"] 

500 if "dfc" in tool_section: 

501 return tool_section["dfc"] 

502 elif "docstring-format-checker" in tool_section: 

503 return tool_section["docstring-format-checker"] 

504 

505 return None 

506 

507 

508def _parse_global_config(tool_config: dict[str, Any]) -> GlobalConfig: 

509 """ 

510 !!! note "Summary" 

511 Parse global configuration flags. 

512 

513 Params: 

514 tool_config (dict[str, Any]): 

515 Tool configuration dictionary. 

516 

517 Returns: 

518 (GlobalConfig): 

519 Parsed global configuration object. 

520 """ 

521 # Validate optional_style if provided 

522 optional_style: str = tool_config.get("optional_style", "validate") 

523 valid_styles: tuple[str, str, str] = ("silent", "validate", "strict") 

524 if optional_style not in valid_styles: 

525 raise InvalidConfigError( 

526 f"Invalid optional_style: '{optional_style}'. Must be one of: {', '.join(valid_styles)}" 

527 ) 

528 

529 return GlobalConfig( 

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

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

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

533 validate_param_types=tool_config.get("validate_param_types", True), 

534 optional_style=optional_style, # type:ignore 

535 ) 

536 

537 

538def _parse_sections_config(tool_config: dict[str, Any]) -> list[SectionConfig]: 

539 """ 

540 !!! note "Summary" 

541 Parse sections configuration. 

542 

543 Params: 

544 tool_config (dict[str, Any]): 

545 Tool configuration dictionary. 

546 

547 Returns: 

548 (list[SectionConfig]): 

549 List of section configuration objects or defaults. 

550 """ 

551 if "sections" not in tool_config: 

552 return DEFAULT_SECTIONS 

553 

554 sections_config: list[SectionConfig] = [] 

555 sections_data = tool_config["sections"] 

556 

557 for section_data in sections_data: 

558 try: 

559 # Get admonition value with proper default handling 

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

561 if admonition_value is None: 

562 admonition_value = False # Use SectionConfig default 

563 

564 section = SectionConfig( 

565 order=section_data.get("order"), 

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

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

568 admonition=admonition_value, 

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

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

571 ) 

572 sections_config.append(section) 

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

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

575 

576 # Validate and sort sections 

577 if sections_config: 

578 _validate_config_order(config_sections=sections_config) 

579 sections_config.sort(key=lambda x: x.order if x.order is not None else float("inf")) 

580 else: 

581 sections_config = DEFAULT_SECTIONS 

582 

583 return sections_config 

584 

585 

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

587 """ 

588 !!! note "Summary" 

589 Find configuration file by searching up the directory tree. 

590 

591 Params: 

592 start_path (Optional[Path]): 

593 Directory to start searching from. 

594 If `None`, resolves to current directory. 

595 Default: `None`. 

596 

597 Returns: 

598 (Optional[Path]): 

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

600 """ 

601 if start_path is None: 

602 start_path = Path.cwd() 

603 

604 current_path: Path = start_path.resolve() 

605 

606 while current_path != current_path.parent: 

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

608 if pyproject_path.exists(): 

609 # Check if it contains dfc configuration 

610 try: 

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

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

613 if "tool" in config_data and ( 

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

615 ): 

616 return pyproject_path 

617 except Exception: 

618 pass 

619 

620 current_path = current_path.parent 

621 

622 return None