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
« 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# ============================================================================ #
15# ---------------------------------------------------------------------------- #
16# #
17# Overview ####
18# #
19# ---------------------------------------------------------------------------- #
22# ---------------------------------------------------------------------------- #
23# Description ####
24# ---------------------------------------------------------------------------- #
27"""
28!!! note "Summary"
29 Configuration handling for the docstring format checker.
30"""
33# ---------------------------------------------------------------------------- #
34# #
35# Setup ####
36# #
37# ---------------------------------------------------------------------------- #
40## --------------------------------------------------------------------------- #
41## Imports ####
42## --------------------------------------------------------------------------- #
45# ## Python StdLib Imports ----
46import sys
47from dataclasses import dataclass
48from pathlib import Path
49from typing import Any, Literal, Optional, Union
51# ## Local First Party Imports ----
52from docstring_format_checker.utils.exceptions import (
53 InvalidConfigError,
54 InvalidConfigError_DuplicateOrderValues,
55 InvalidTypeValuesError,
56)
59if sys.version_info >= (3, 11):
60 # ## Python StdLib Imports ----
61 import tomllib
62else:
63 # ## Python Third Party Imports ----
64 import tomli as tomllib
67## --------------------------------------------------------------------------- #
68## Exports ####
69## --------------------------------------------------------------------------- #
72__all__: list[str] = [
73 "GlobalConfig",
74 "SectionConfig",
75 "Config",
76 "DEFAULT_CONFIG",
77 "load_config",
78 "find_config_file",
79]
82## --------------------------------------------------------------------------- #
83## Constants ####
84## --------------------------------------------------------------------------- #
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)
95# ---------------------------------------------------------------------------- #
96# #
97# Config ####
98# #
99# ---------------------------------------------------------------------------- #
102## --------------------------------------------------------------------------- #
103## GlobalConfig ####
104## --------------------------------------------------------------------------- #
107@dataclass
108class GlobalConfig:
109 """
110 !!! note "Summary"
111 Global configuration for docstring checking behavior.
112 """
114 allow_undefined_sections: bool = False
115 require_docstrings: bool = True
116 check_private: bool = False
119## --------------------------------------------------------------------------- #
120## SectionConfig ####
121## --------------------------------------------------------------------------- #
124@dataclass
125class SectionConfig:
126 """
127 !!! note "Summary"
128 Configuration for a docstring section.
129 """
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
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()
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}")
155 def _validate_admonition_prefix_combination(self) -> None:
156 """
157 !!! note "Summary"
158 Validate admonition and prefix combination rules.
159 """
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")
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")
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")
175 else:
176 raise ValueError(
177 f"Section '{self.name}': admonition must be a boolean or string, got {type(self.admonition)}"
178 )
181## --------------------------------------------------------------------------- #
182## Validations ####
183## --------------------------------------------------------------------------- #
186def _validate_config_order(config_sections: list[SectionConfig]) -> None:
187 """
188 !!! note "Summary"
189 Validate that section order values are unique.
191 Params:
192 config_sections (list[SectionConfig]):
193 List of section configurations to validate.
195 Raises:
196 (InvalidConfigError_DuplicateOrderValues):
197 If duplicate order values are found.
199 Returns:
200 (None):
201 Nothing is returned.
202 """
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()
209 for order in order_values:
210 if order in seen_orders:
211 duplicate_orders.add(order)
212 else:
213 seen_orders.add(order)
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 )
222# ---------------------------------------------------------------------------- #
223# #
224# Config Container ####
225# #
226# ---------------------------------------------------------------------------- #
229@dataclass
230class Config:
231 """
232 !!! note "Summary"
233 Complete configuration containing global settings and section definitions.
234 """
236 global_config: GlobalConfig
237 sections: list[SectionConfig]
240# ---------------------------------------------------------------------------- #
241# #
242# Default Configuration ####
243# #
244# ---------------------------------------------------------------------------- #
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]
307DEFAULT_CONFIG: Config = Config(
308 global_config=GlobalConfig(),
309 sections=DEFAULT_SECTIONS,
310)
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.
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`.
324 Raises:
325 (FileNotFoundError):
326 If the specified config file doesn't exist.
327 (InvalidConfigError):
328 If the configuration is invalid.
330 Returns:
331 (Config):
332 Configuration object containing global settings and section definitions.
333 """
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
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}")
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
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"]
362 if tool_config is None:
363 return DEFAULT_CONFIG
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 )
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
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
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)
402 # Sort by order
403 sections_config.sort(key=lambda x: x.order)
405 return Config(global_config=global_config, sections=sections_config)
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.
413 Params:
414 start_path (Optional[Path]):
415 Directory to start searching from.
416 If `None`, resolves to current directory.
417 Default: `None`.
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()
426 current_path: Path = start_path.resolve()
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
442 current_path = current_path.parent
444 return None