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

85 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-04 12:45 +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 "SectionConfig", 

74 "DEFAULT_CONFIG", 

75 "load_config", 

76 "find_config_file", 

77] 

78 

79 

80## --------------------------------------------------------------------------- # 

81## Constants #### 

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

83 

84 

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

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

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

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

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

90) 

91 

92 

93# ---------------------------------------------------------------------------- # 

94# # 

95# Helpers #### 

96# # 

97# ---------------------------------------------------------------------------- # 

98 

99 

100## --------------------------------------------------------------------------- # 

101## Classes #### 

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

103 

104 

105@dataclass 

106class SectionConfig: 

107 """ 

108 Configuration for a docstring section. 

109 """ 

110 

111 order: int 

112 name: str 

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

114 admonition: str = "" 

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

116 required: bool = False 

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

118 

119 def __post_init__(self) -> None: 

120 """Validate configuration after initialization.""" 

121 if self.type not in VALID_TYPES: 

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

123 

124 

125## --------------------------------------------------------------------------- # 

126## Validations #### 

127## --------------------------------------------------------------------------- # 

128 

129 

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

131 

132 # Validate no duplicate order values 

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

134 seen_orders: set[int] = set() 

135 duplicate_orders: set[int] = set() 

136 

137 for order in order_values: 

138 if order in seen_orders: 

139 duplicate_orders.add(order) 

140 else: 

141 seen_orders.add(order) 

142 

143 if duplicate_orders: 

144 raise InvalidConfigError_DuplicateOrderValues( 

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

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

147 ) 

148 

149 

150# ---------------------------------------------------------------------------- # 

151# # 

152# Main Section #### 

153# # 

154# ---------------------------------------------------------------------------- # 

155 

156 

157DEFAULT_CONFIG: list[SectionConfig] = [ 

158 SectionConfig( 

159 order=1, 

160 name="summary", 

161 type="free_text", 

162 admonition="note", 

163 prefix="!!!", 

164 required=True, 

165 ), 

166 SectionConfig( 

167 order=2, 

168 name="details", 

169 type="free_text", 

170 admonition="info", 

171 prefix="???+", 

172 required=False, 

173 ), 

174 SectionConfig( 

175 order=3, 

176 name="params", 

177 type="list_name_and_type", 

178 required=True, 

179 ), 

180 SectionConfig( 

181 order=4, 

182 name="returns", 

183 type="list_name_and_type", 

184 required=False, 

185 ), 

186 SectionConfig( 

187 order=5, 

188 name="yields", 

189 type="list_type", 

190 required=False, 

191 ), 

192 SectionConfig( 

193 order=6, 

194 name="raises", 

195 type="list_type", 

196 required=False, 

197 ), 

198 SectionConfig( 

199 order=7, 

200 name="examples", 

201 type="free_text", 

202 admonition="example", 

203 prefix="???+", 

204 required=False, 

205 ), 

206 SectionConfig( 

207 order=8, 

208 name="notes", 

209 type="free_text", 

210 admonition="note", 

211 prefix="???", 

212 required=False, 

213 ), 

214] 

215 

216 

217def load_config(config_path: Optional[Union[str, Path]] = None) -> list[SectionConfig]: 

218 """ 

219 !!! note "Summary" 

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

221 

222 Params: 

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

224 Path to the TOML configuration file. 

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

226 Default: `None`. 

227 

228 Returns: 

229 (list[SectionConfig]): 

230 List of SectionConfig objects defining the docstring sections to check. 

231 

232 Raises: 

233 (FileNotFoundError): 

234 If the specified config file doesn't exist. 

235 (InvalidConfigError): 

236 If the configuration is invalid. 

237 """ 

238 

239 if config_path is None: 

240 # Look for pyproject.toml in current directory 

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

242 if pyproject_path.exists(): 

243 config_path = pyproject_path 

244 else: 

245 return DEFAULT_CONFIG 

246 

247 config_path = Path(config_path) 

248 if not config_path.exists(): 

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

250 

251 try: 

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

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

254 except Exception as e: 

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

256 

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

258 tool_config = None 

259 if "tool" in config_data: 

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

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

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

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

264 

265 if tool_config is None: 

266 return DEFAULT_CONFIG 

267 

268 # Parse sections configuration 

269 sections_config: list[SectionConfig] = [] 

270 if "sections" in tool_config: 

271 sections_data = tool_config["sections"] 

272 for section_data in sections_data: 

273 try: 

274 section = SectionConfig( 

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

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

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

278 admonition=section_data.get("admonition", ""), 

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

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

281 ) 

282 sections_config.append(section) 

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

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

285 

286 if not sections_config: 

287 return DEFAULT_CONFIG 

288 

289 # Validate no duplicate order values 

290 _validate_config_order(config_sections=sections_config) 

291 

292 # Sort by order 

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

294 

295 return sections_config 

296 

297 

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

299 """ 

300 !!! note "Summary" 

301 Find configuration file by searching up the directory tree. 

302 

303 Params: 

304 start_path (Optional[Path]): 

305 Directory to start searching from. 

306 If `None`, resolves to current directory. 

307 Default: `None`. 

308 

309 Returns: 

310 (Optional[Path]): 

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

312 """ 

313 if start_path is None: 

314 start_path = Path.cwd() 

315 

316 current_path: Path = start_path.resolve() 

317 

318 while current_path != current_path.parent: 

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

320 if pyproject_path.exists(): 

321 # Check if it contains dfc configuration 

322 try: 

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

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

325 if "tool" in config_data and ( 

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

327 ): 

328 return pyproject_path 

329 except Exception: 

330 pass 

331 

332 current_path = current_path.parent 

333 

334 return None