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
« 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# ============================================================================ #
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, field
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 = 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 )
151## --------------------------------------------------------------------------- #
152## SectionConfig ####
153## --------------------------------------------------------------------------- #
156@dataclass
157class SectionConfig:
158 """
159 !!! note "Summary"
160 Configuration for a docstring section.
161 """
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 )
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()
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}")
227 def _validate_admonition_prefix_combination(self) -> None:
228 """
229 !!! note "Summary"
230 Validate admonition and prefix combination rules.
231 """
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")
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")
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")
247 else:
248 raise ValueError(
249 f"Section '{self.name}': admonition must be a boolean or string, got {type(self.admonition)}"
250 )
253## --------------------------------------------------------------------------- #
254## Validations ####
255## --------------------------------------------------------------------------- #
258def _validate_config_order(config_sections: list[SectionConfig]) -> None:
259 """
260 !!! note "Summary"
261 Validate that section order values are unique.
263 Params:
264 config_sections (list[SectionConfig]):
265 List of section configurations to validate.
267 Raises:
268 (InvalidConfigError_DuplicateOrderValues):
269 If duplicate order values are found.
271 Returns:
272 (None):
273 Nothing is returned.
274 """
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()
281 for order in order_values:
282 if order in seen_orders:
283 duplicate_orders.add(order)
284 else:
285 seen_orders.add(order)
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 )
294# ---------------------------------------------------------------------------- #
295# #
296# Config Container ####
297# #
298# ---------------------------------------------------------------------------- #
301@dataclass
302class Config:
303 """
304 !!! note "Summary"
305 Complete configuration containing global settings and section definitions.
306 """
308 global_config: GlobalConfig
309 sections: list[SectionConfig]
312# ---------------------------------------------------------------------------- #
313# #
314# Default Configuration ####
315# #
316# ---------------------------------------------------------------------------- #
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]
379DEFAULT_CONFIG: Config = Config(
380 global_config=GlobalConfig(),
381 sections=DEFAULT_SECTIONS,
382)
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.
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`.
396 Raises:
397 (FileNotFoundError):
398 If the specified config file doesn't exist.
399 (InvalidConfigError):
400 If the configuration is invalid.
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
411 # Parse TOML configuration
412 config_data = _parse_toml_file(resolved_path)
414 # Extract tool configuration
415 tool_config = _extract_tool_config(config_data)
416 if tool_config is None:
417 return DEFAULT_CONFIG
419 # Parse configuration components
420 global_config = _parse_global_config(tool_config)
421 sections_config = _parse_sections_config(tool_config)
423 return Config(global_config=global_config, sections=sections_config)
426def _resolve_config_path(config_path: Optional[Union[str, Path]]) -> Optional[Path]:
427 """
428 !!! note "Summary"
429 Resolve configuration file path.
431 Params:
432 config_path (Optional[Union[str, Path]]):
433 Optional path to configuration file.
435 Raises:
436 (FileNotFoundError):
437 If specified config file does not exist.
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
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}")
456 return config_path
459def _parse_toml_file(config_path: Path) -> dict[str, Any]:
460 """
461 !!! note "Summary"
462 Parse TOML configuration file.
464 Params:
465 config_path (Path):
466 Path to TOML file to parse.
468 Raises:
469 (InvalidConfigError):
470 If TOML parsing fails.
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
483def _extract_tool_config(config_data: dict[str, Any]) -> Optional[dict[str, Any]]:
484 """
485 !!! note "Summary"
486 Extract tool configuration from TOML data.
488 Params:
489 config_data (dict[str, Any]):
490 Parsed TOML data dictionary.
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
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"]
505 return None
508def _parse_global_config(tool_config: dict[str, Any]) -> GlobalConfig:
509 """
510 !!! note "Summary"
511 Parse global configuration flags.
513 Params:
514 tool_config (dict[str, Any]):
515 Tool configuration dictionary.
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 )
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 )
538def _parse_sections_config(tool_config: dict[str, Any]) -> list[SectionConfig]:
539 """
540 !!! note "Summary"
541 Parse sections configuration.
543 Params:
544 tool_config (dict[str, Any]):
545 Tool configuration dictionary.
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
554 sections_config: list[SectionConfig] = []
555 sections_data = tool_config["sections"]
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
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
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
583 return sections_config
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.
591 Params:
592 start_path (Optional[Path]):
593 Directory to start searching from.
594 If `None`, resolves to current directory.
595 Default: `None`.
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()
604 current_path: Path = start_path.resolve()
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
620 current_path = current_path.parent
622 return None