Coverage for src / tracekit / extensibility / docs.py: 72%
318 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Extension documentation auto-generation system.
3This module provides automatic documentation generation for TraceKit extensions
4including API reference, usage examples, and metadata extraction from docstrings.
7Example:
8 >>> from tracekit.extensibility.docs import generate_extension_docs
9 >>> from pathlib import Path
10 >>>
11 >>> # Generate documentation for an extension
12 >>> docs = generate_extension_docs(Path("my_plugin/"))
13 >>> print(docs.markdown)
14"""
16from __future__ import annotations
18import ast
19import inspect
20import logging
21from dataclasses import dataclass, field
22from typing import TYPE_CHECKING, Any
24if TYPE_CHECKING:
25 from pathlib import Path
27logger = logging.getLogger(__name__)
30@dataclass
31class FunctionDoc:
32 """Documentation for a function or method.
34 Attributes:
35 name: Function name
36 signature: Full function signature
37 docstring: Function docstring
38 parameters: List of parameter descriptions
39 returns: Return value description
40 examples: Code examples from docstring
41 """
43 name: str
44 signature: str = ""
45 docstring: str = ""
46 parameters: list[tuple[str, str]] = field(default_factory=list)
47 returns: str = ""
48 examples: list[str] = field(default_factory=list)
51@dataclass
52class ClassDoc:
53 """Documentation for a class.
55 Attributes:
56 name: Class name
57 docstring: Class docstring
58 methods: List of public method documentation
59 attributes: List of class/instance attributes
60 bases: List of base class names
61 """
63 name: str
64 docstring: str = ""
65 methods: list[FunctionDoc] = field(default_factory=list)
66 attributes: list[tuple[str, str]] = field(default_factory=list)
67 bases: list[str] = field(default_factory=list)
70@dataclass
71class ModuleDoc:
72 """Documentation for a Python module.
74 Attributes:
75 name: Module name
76 docstring: Module docstring
77 classes: List of class documentation
78 functions: List of function documentation
79 path: Source file path
80 """
82 name: str
83 docstring: str = ""
84 classes: list[ClassDoc] = field(default_factory=list)
85 functions: list[FunctionDoc] = field(default_factory=list)
86 path: str = ""
89@dataclass
90class ExtensionDocs:
91 """Complete documentation for an extension.
93 Attributes:
94 name: Extension name
95 version: Extension version
96 description: Extension description
97 author: Extension author
98 modules: List of module documentation
99 metadata: Extension metadata
100 markdown: Generated markdown documentation
101 html: Generated HTML documentation
102 """
104 name: str
105 version: str = "0.1.0"
106 description: str = ""
107 author: str = ""
108 modules: list[ModuleDoc] = field(default_factory=list)
109 metadata: dict[str, Any] = field(default_factory=dict)
110 markdown: str = ""
111 html: str = ""
114def generate_extension_docs(
115 extension_path: Path,
116 *,
117 include_private: bool = False,
118 include_examples: bool = True,
119 output_format: str = "markdown",
120) -> ExtensionDocs:
121 """Generate documentation for an extension.
123 Extracts documentation from Python modules, docstrings, and metadata files
124 to create comprehensive API documentation.
126 Args:
127 extension_path: Path to extension directory
128 include_private: Include private members (starting with _)
129 include_examples: Extract examples from docstrings
130 output_format: Output format ("markdown" or "html")
132 Returns:
133 ExtensionDocs object with generated documentation
135 Example:
136 >>> from pathlib import Path
137 >>> docs = generate_extension_docs(Path("plugins/my_decoder/"))
138 >>> print(docs.markdown)
139 >>> with open("docs/my_decoder.md", "w") as f:
140 ... f.write(docs.markdown)
142 References:
143 EXT-006: Extension Documentation
144 """
145 docs = ExtensionDocs(name=extension_path.name)
147 # Extract metadata
148 _extract_metadata(extension_path, docs)
150 # Document Python modules
151 _document_modules(extension_path, docs, include_private, include_examples)
153 # Generate output
154 if output_format == "markdown":
155 docs.markdown = _generate_markdown(docs)
156 elif output_format == "html": 156 ↛ 159line 156 didn't jump to line 159 because the condition on line 156 was always true
157 docs.html = _generate_html(docs)
159 return docs
162def generate_decoder_docs(
163 decoder_class: type,
164 *,
165 include_examples: bool = True,
166) -> str:
167 """Generate documentation for a decoder class.
169 Args:
170 decoder_class: Decoder class to document
171 include_examples: Include usage examples
173 Returns:
174 Markdown documentation string
176 Example:
177 >>> class MyDecoder:
178 ... '''Custom UART decoder.
179 ...
180 ... Example:
181 ... >>> decoder = MyDecoder()
182 ... >>> frames = decoder.decode(signal)
183 ... '''
184 ... def decode(self, signal):
185 ... '''Decode signal.'''
186 ... return []
187 >>> docs = generate_decoder_docs(MyDecoder)
188 >>> print(docs)
190 References:
191 EXT-006: Extension Documentation
192 """
193 class_doc = _document_class(
194 decoder_class, include_private=False, include_examples=include_examples
195 )
197 # Generate markdown
198 lines = []
199 lines.append(f"# {class_doc.name}")
200 lines.append("")
202 if class_doc.docstring: 202 ↛ 206line 202 didn't jump to line 206 because the condition on line 202 was always true
203 lines.append(class_doc.docstring)
204 lines.append("")
206 if class_doc.bases: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 lines.append(f"**Inherits from:** {', '.join(class_doc.bases)}")
208 lines.append("")
210 # Attributes
211 if class_doc.attributes: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 lines.append("## Attributes")
213 lines.append("")
214 for name, desc in class_doc.attributes:
215 lines.append(f"- **{name}**: {desc}")
216 lines.append("")
218 # Methods
219 if class_doc.methods: 219 ↛ 251line 219 didn't jump to line 251 because the condition on line 219 was always true
220 lines.append("## Methods")
221 lines.append("")
222 for method in class_doc.methods:
223 lines.append(f"### {method.name}")
224 lines.append("")
225 if method.signature: 225 ↛ 230line 225 didn't jump to line 230 because the condition on line 225 was always true
226 lines.append("```python")
227 lines.append(f"{method.signature}")
228 lines.append("```")
229 lines.append("")
230 if method.docstring: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true
231 lines.append(method.docstring)
232 lines.append("")
233 if method.parameters: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 lines.append("**Parameters:**")
235 lines.append("")
236 for param_name, param_desc in method.parameters:
237 lines.append(f"- **{param_name}**: {param_desc}")
238 lines.append("")
239 if method.returns: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 lines.append(f"**Returns:** {method.returns}")
241 lines.append("")
242 if method.examples: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 lines.append("**Example:**")
244 lines.append("")
245 for example in method.examples:
246 lines.append("```python")
247 lines.append(example)
248 lines.append("```")
249 lines.append("")
251 return "\n".join(lines)
254def extract_plugin_metadata(
255 extension_path: Path,
256) -> dict[str, Any]:
257 """Extract metadata from extension directory.
259 Args:
260 extension_path: Path to extension directory
262 Returns:
263 Dictionary with metadata fields
265 Example:
266 >>> metadata = extract_plugin_metadata(Path("plugins/my_plugin/"))
267 >>> print(metadata["name"])
268 >>> print(metadata["version"])
270 References:
271 EXT-006: Extension Documentation
272 """
273 metadata: dict[str, Any] = {}
275 # Try pyproject.toml
276 pyproject = extension_path / "pyproject.toml"
277 if pyproject.exists():
278 try:
279 import tomllib
281 with open(pyproject, "rb") as f:
282 data = tomllib.load(f)
284 if "project" in data: 284 ↛ 301line 284 didn't jump to line 301 because the condition on line 284 was always true
285 project = data["project"]
286 metadata.update(
287 {
288 "name": project.get("name", ""),
289 "version": project.get("version", ""),
290 "description": project.get("description", ""),
291 "authors": project.get("authors", []),
292 "dependencies": project.get("dependencies", []),
293 "entry_points": project.get("entry-points", {}),
294 }
295 )
297 except Exception as e:
298 logger.warning(f"Failed to parse pyproject.toml: {e}")
300 # Try plugin.yaml
301 plugin_yaml = extension_path / "plugin.yaml"
302 if plugin_yaml.exists(): 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 try:
304 import yaml
306 with open(plugin_yaml, encoding="utf-8") as f:
307 data = yaml.safe_load(f)
309 if data:
310 metadata.update(data)
312 except Exception as e:
313 logger.warning(f"Failed to parse plugin.yaml: {e}")
315 return metadata
318def _extract_metadata(extension_path: Path, docs: ExtensionDocs) -> None:
319 """Extract metadata from extension files.
321 Args:
322 extension_path: Path to extension directory
323 docs: ExtensionDocs to populate
324 """
325 metadata = extract_plugin_metadata(extension_path)
327 docs.name = metadata.get("name", extension_path.name)
328 docs.version = metadata.get("version", "0.1.0")
329 docs.description = metadata.get("description", "")
330 docs.metadata = metadata
332 # Extract author
333 authors = metadata.get("authors", [])
334 if authors and isinstance(authors, list) and len(authors) > 0: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 if isinstance(authors[0], dict):
336 docs.author = authors[0].get("name", "")
337 else:
338 docs.author = str(authors[0])
341def _document_modules(
342 extension_path: Path,
343 docs: ExtensionDocs,
344 include_private: bool,
345 include_examples: bool,
346) -> None:
347 """Document all Python modules in extension.
349 Args:
350 extension_path: Path to extension directory
351 docs: ExtensionDocs to populate
352 include_private: Include private members
353 include_examples: Extract examples from docstrings
354 """
355 # Find Python files
356 py_files = list(extension_path.glob("*.py"))
357 py_files = [f for f in py_files if f.name != "__init__.py"]
359 for py_file in py_files:
360 try:
361 module_doc = _document_module(py_file, include_private, include_examples)
362 docs.modules.append(module_doc)
363 except Exception as e:
364 logger.warning(f"Failed to document {py_file}: {e}")
367def _document_module(
368 module_path: Path,
369 include_private: bool,
370 include_examples: bool,
371) -> ModuleDoc:
372 """Document a Python module.
374 Args:
375 module_path: Path to Python file
376 include_private: Include private members
377 include_examples: Extract examples
379 Returns:
380 ModuleDoc with extracted documentation
381 """
382 with open(module_path, encoding="utf-8") as f:
383 source = f.read()
385 tree = ast.parse(source)
387 module_doc = ModuleDoc(
388 name=module_path.stem,
389 path=str(module_path),
390 docstring=ast.get_docstring(tree) or "",
391 )
393 # Extract classes and functions
394 for node in ast.iter_child_nodes(tree):
395 if isinstance(node, ast.ClassDef):
396 if include_private or not node.name.startswith("_"): 396 ↛ 394line 396 didn't jump to line 394 because the condition on line 396 was always true
397 class_doc = _document_class_ast(node, include_private, include_examples)
398 module_doc.classes.append(class_doc)
400 elif isinstance(node, ast.FunctionDef):
401 if include_private or not node.name.startswith("_"): 401 ↛ 394line 401 didn't jump to line 394 because the condition on line 401 was always true
402 func_doc = _document_function_ast(node, include_examples)
403 module_doc.functions.append(func_doc)
405 return module_doc
408def _document_class(
409 cls: type,
410 include_private: bool,
411 include_examples: bool,
412) -> ClassDoc:
413 """Document a class from runtime object.
415 Args:
416 cls: Class to document
417 include_private: Include private members
418 include_examples: Extract examples
420 Returns:
421 ClassDoc with extracted documentation
422 """
423 class_doc = ClassDoc(
424 name=cls.__name__,
425 docstring=inspect.getdoc(cls) or "",
426 bases=[base.__name__ for base in cls.__bases__ if base is not object],
427 )
429 # Document methods
430 for name, obj in inspect.getmembers(cls):
431 if include_private or not name.startswith("_"):
432 if inspect.isfunction(obj) or inspect.ismethod(obj): 432 ↛ 430line 432 didn't jump to line 430 because the condition on line 432 was always true
433 try:
434 sig = str(inspect.signature(obj))
435 func_doc = FunctionDoc(
436 name=name,
437 signature=f"def {name}{sig}",
438 docstring=inspect.getdoc(obj) or "",
439 )
440 class_doc.methods.append(func_doc)
441 except Exception:
442 pass
444 return class_doc
447def _document_class_ast(
448 node: ast.ClassDef,
449 include_private: bool,
450 include_examples: bool,
451) -> ClassDoc:
452 """Document a class from AST node.
454 Args:
455 node: AST ClassDef node
456 include_private: Include private members
457 include_examples: Extract examples
459 Returns:
460 ClassDoc with extracted documentation
461 """
462 class_doc = ClassDoc(
463 name=node.name,
464 docstring=ast.get_docstring(node) or "",
465 bases=[_get_name_from_ast(base) for base in node.bases],
466 )
468 # Document methods
469 for item in node.body:
470 if isinstance(item, ast.FunctionDef):
471 if include_private or not item.name.startswith("_"):
472 func_doc = _document_function_ast(item, include_examples)
473 class_doc.methods.append(func_doc)
475 return class_doc
478def _document_function_ast(
479 node: ast.FunctionDef,
480 include_examples: bool,
481) -> FunctionDoc:
482 """Document a function from AST node.
484 Args:
485 node: AST FunctionDef node
486 include_examples: Extract examples
488 Returns:
489 FunctionDoc with extracted documentation
490 """
491 # Build signature
492 args = []
493 for arg in node.args.args:
494 args.append(arg.arg)
496 signature = f"def {node.name}({', '.join(args)})"
498 func_doc = FunctionDoc(
499 name=node.name,
500 signature=signature,
501 docstring=ast.get_docstring(node) or "",
502 )
504 # Parse docstring for parameters, returns, examples
505 if func_doc.docstring: 505 ↛ 508line 505 didn't jump to line 508 because the condition on line 505 was always true
506 _parse_docstring(func_doc, include_examples)
508 return func_doc
511def _parse_docstring(func_doc: FunctionDoc, include_examples: bool) -> None:
512 """Parse Google-style docstring for structured information.
514 Args:
515 func_doc: FunctionDoc to populate
516 include_examples: Extract examples
517 """
518 lines = func_doc.docstring.split("\n")
519 current_section = None
520 section_content: list[str] = []
522 for line in lines:
523 line_stripped = line.strip()
525 # Detect sections
526 if line_stripped.endswith(":") and line_stripped[:-1] in [
527 "Args",
528 "Arguments",
529 "Parameters",
530 "Returns",
531 "Return",
532 "Example",
533 "Examples",
534 ]:
535 # Process previous section
536 if current_section:
537 _process_section(func_doc, current_section, section_content, include_examples)
539 current_section = line_stripped[:-1].lower()
540 section_content = []
541 else:
542 section_content.append(line)
544 # Process final section
545 if current_section:
546 _process_section(func_doc, current_section, section_content, include_examples)
549def _process_section(
550 func_doc: FunctionDoc,
551 section: str,
552 content: list[str],
553 include_examples: bool,
554) -> None:
555 """Process a docstring section.
557 Args:
558 func_doc: FunctionDoc to populate
559 section: Section name
560 content: Section content lines
561 include_examples: Extract examples
562 """
563 if section in ["args", "arguments", "parameters"]:
564 # Parse parameters
565 for line in content:
566 line = line.strip()
567 if ":" in line:
568 parts = line.split(":", 1)
569 param_name = parts[0].strip()
570 param_desc = parts[1].strip()
571 func_doc.parameters.append((param_name, param_desc))
573 elif section in ["returns", "return"]: 573 ↛ 576line 573 didn't jump to line 576 because the condition on line 573 was always true
574 func_doc.returns = "\n".join(content).strip()
576 elif section in ["example", "examples"] and include_examples:
577 # Extract code blocks
578 in_code = False
579 code_lines = []
581 for line in content:
582 if ">>>" in line or "..." in line:
583 in_code = True
584 code_lines.append(line.strip())
585 elif in_code:
586 if line.strip() and not line.strip().startswith("#"):
587 if not (">>>" in line or "..." in line):
588 in_code = False
589 if code_lines:
590 func_doc.examples.append("\n".join(code_lines))
591 code_lines = []
592 else:
593 code_lines.append(line.strip())
595 if code_lines:
596 func_doc.examples.append("\n".join(code_lines))
599def _get_name_from_ast(node: ast.expr) -> str:
600 """Extract name from AST expression.
602 Args:
603 node: AST expression node
605 Returns:
606 Name string
607 """
608 if isinstance(node, ast.Name):
609 return node.id
610 elif isinstance(node, ast.Attribute):
611 return node.attr
612 else:
613 return str(node)
616def _generate_markdown(docs: ExtensionDocs) -> str:
617 """Generate markdown documentation.
619 Args:
620 docs: ExtensionDocs to render
622 Returns:
623 Markdown string
624 """
625 lines = []
627 # Title
628 lines.append(f"# {docs.name}")
629 lines.append("")
631 # Metadata
632 if docs.version: 632 ↛ 635line 632 didn't jump to line 635 because the condition on line 632 was always true
633 lines.append(f"**Version:** {docs.version}")
634 lines.append("")
635 if docs.author: 635 ↛ 636line 635 didn't jump to line 636 because the condition on line 635 was never true
636 lines.append(f"**Author:** {docs.author}")
637 lines.append("")
638 if docs.description: 638 ↛ 643line 638 didn't jump to line 643 because the condition on line 638 was always true
639 lines.append(docs.description)
640 lines.append("")
642 # Dependencies
643 if "dependencies" in docs.metadata: 643 ↛ 651line 643 didn't jump to line 651 because the condition on line 643 was always true
644 lines.append("## Dependencies")
645 lines.append("")
646 for dep in docs.metadata["dependencies"]: 646 ↛ 647line 646 didn't jump to line 647 because the loop on line 646 never started
647 lines.append(f"- {dep}")
648 lines.append("")
650 # Modules
651 for module in docs.modules:
652 lines.append(f"## Module: {module.name}")
653 lines.append("")
654 if module.docstring: 654 ↛ 659line 654 didn't jump to line 659 because the condition on line 654 was always true
655 lines.append(module.docstring)
656 lines.append("")
658 # Classes
659 for cls in module.classes:
660 lines.append(f"### Class: {cls.name}")
661 lines.append("")
662 if cls.docstring: 662 ↛ 667line 662 didn't jump to line 667 because the condition on line 662 was always true
663 lines.append(cls.docstring)
664 lines.append("")
666 # Methods
667 if cls.methods: 667 ↛ 659line 667 didn't jump to line 659 because the condition on line 667 was always true
668 lines.append("#### Methods")
669 lines.append("")
670 for method in cls.methods:
671 lines.append(f"##### {method.name}")
672 lines.append("")
673 if method.signature: 673 ↛ 678line 673 didn't jump to line 678 because the condition on line 673 was always true
674 lines.append("```python")
675 lines.append(method.signature)
676 lines.append("```")
677 lines.append("")
678 if method.docstring: 678 ↛ 670line 678 didn't jump to line 670 because the condition on line 678 was always true
679 lines.append(method.docstring)
680 lines.append("")
682 # Functions
683 for func in module.functions:
684 lines.append(f"### Function: {func.name}")
685 lines.append("")
686 if func.signature: 686 ↛ 691line 686 didn't jump to line 691 because the condition on line 686 was always true
687 lines.append("```python")
688 lines.append(func.signature)
689 lines.append("```")
690 lines.append("")
691 if func.docstring: 691 ↛ 683line 691 didn't jump to line 683 because the condition on line 691 was always true
692 lines.append(func.docstring)
693 lines.append("")
695 return "\n".join(lines)
698def _generate_html(docs: ExtensionDocs) -> str:
699 """Generate HTML documentation.
701 Args:
702 docs: ExtensionDocs to render
704 Returns:
705 HTML string
706 """
707 # Convert markdown to HTML (simple conversion)
708 markdown = _generate_markdown(docs)
710 # Simple markdown-to-HTML conversion
711 html_lines = ["<!DOCTYPE html>", "<html>", "<head>"]
712 html_lines.append(f"<title>{docs.name} Documentation</title>")
713 html_lines.append("<style>")
714 html_lines.append("body { font-family: Arial, sans-serif; margin: 40px; }")
715 html_lines.append("code { background: #f4f4f4; padding: 2px 4px; }")
716 html_lines.append("pre { background: #f4f4f4; padding: 10px; }")
717 html_lines.append("</style>")
718 html_lines.append("</head>")
719 html_lines.append("<body>")
721 # Very simple markdown-to-HTML
722 for line in markdown.split("\n"):
723 if line.startswith("# "):
724 html_lines.append(f"<h1>{line[2:]}</h1>")
725 elif line.startswith("## "):
726 html_lines.append(f"<h2>{line[3:]}</h2>")
727 elif line.startswith("### "): 727 ↛ 728line 727 didn't jump to line 728 because the condition on line 727 was never true
728 html_lines.append(f"<h3>{line[4:]}</h3>")
729 elif line.startswith("```"): 729 ↛ 731line 729 didn't jump to line 731 because the condition on line 729 was never true
730 # Toggle code block
731 if "<pre><code>" not in html_lines[-1] if html_lines else "":
732 html_lines.append("<pre><code>")
733 else:
734 html_lines.append("</code></pre>")
735 elif line.strip():
736 html_lines.append(f"<p>{line}</p>")
738 html_lines.append("</body>")
739 html_lines.append("</html>")
741 return "\n".join(html_lines)
744__all__ = [
745 "ClassDoc",
746 "ExtensionDocs",
747 "FunctionDoc",
748 "ModuleDoc",
749 "extract_plugin_metadata",
750 "generate_decoder_docs",
751 "generate_extension_docs",
752]