truthound-dashboard 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,703 @@
1
+ """Documentation Extractor.
2
+
3
+ This module provides AST-based documentation extraction
4
+ for Python source code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import inspect
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ @dataclass
18
+ class ParameterDoc:
19
+ """Documentation for a function parameter.
20
+
21
+ Attributes:
22
+ name: Parameter name.
23
+ type_hint: Type annotation.
24
+ default: Default value.
25
+ description: Parameter description from docstring.
26
+ required: Whether parameter is required.
27
+ """
28
+
29
+ name: str
30
+ type_hint: str = ""
31
+ default: str | None = None
32
+ description: str = ""
33
+ required: bool = True
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Convert to dictionary."""
37
+ return {
38
+ "name": self.name,
39
+ "type_hint": self.type_hint,
40
+ "default": self.default,
41
+ "description": self.description,
42
+ "required": self.required,
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class FunctionDoc:
48
+ """Documentation for a function or method.
49
+
50
+ Attributes:
51
+ name: Function name.
52
+ signature: Full function signature.
53
+ docstring: Docstring content.
54
+ description: Short description (first line).
55
+ long_description: Full description.
56
+ parameters: List of parameters.
57
+ returns: Return type and description.
58
+ raises: Exceptions that may be raised.
59
+ examples: Usage examples.
60
+ decorators: Applied decorators.
61
+ is_async: Whether function is async.
62
+ is_classmethod: Whether it's a classmethod.
63
+ is_staticmethod: Whether it's a staticmethod.
64
+ line_number: Line number in source.
65
+ """
66
+
67
+ name: str
68
+ signature: str = ""
69
+ docstring: str = ""
70
+ description: str = ""
71
+ long_description: str = ""
72
+ parameters: list[ParameterDoc] = field(default_factory=list)
73
+ returns: dict[str, str] = field(default_factory=dict)
74
+ raises: list[dict[str, str]] = field(default_factory=list)
75
+ examples: list[str] = field(default_factory=list)
76
+ decorators: list[str] = field(default_factory=list)
77
+ is_async: bool = False
78
+ is_classmethod: bool = False
79
+ is_staticmethod: bool = False
80
+ line_number: int = 0
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Convert to dictionary."""
84
+ return {
85
+ "name": self.name,
86
+ "signature": self.signature,
87
+ "docstring": self.docstring,
88
+ "description": self.description,
89
+ "long_description": self.long_description,
90
+ "parameters": [p.to_dict() for p in self.parameters],
91
+ "returns": self.returns,
92
+ "raises": self.raises,
93
+ "examples": self.examples,
94
+ "decorators": self.decorators,
95
+ "is_async": self.is_async,
96
+ "is_classmethod": self.is_classmethod,
97
+ "is_staticmethod": self.is_staticmethod,
98
+ "line_number": self.line_number,
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class ClassDoc:
104
+ """Documentation for a class.
105
+
106
+ Attributes:
107
+ name: Class name.
108
+ docstring: Class docstring.
109
+ description: Short description.
110
+ long_description: Full description.
111
+ bases: Base classes.
112
+ methods: List of method documentation.
113
+ attributes: Class attributes.
114
+ class_attributes: Class-level attributes.
115
+ decorators: Applied decorators.
116
+ is_dataclass: Whether it's a dataclass.
117
+ line_number: Line number in source.
118
+ """
119
+
120
+ name: str
121
+ docstring: str = ""
122
+ description: str = ""
123
+ long_description: str = ""
124
+ bases: list[str] = field(default_factory=list)
125
+ methods: list[FunctionDoc] = field(default_factory=list)
126
+ attributes: list[dict[str, Any]] = field(default_factory=list)
127
+ class_attributes: list[dict[str, Any]] = field(default_factory=list)
128
+ decorators: list[str] = field(default_factory=list)
129
+ is_dataclass: bool = False
130
+ line_number: int = 0
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ """Convert to dictionary."""
134
+ return {
135
+ "name": self.name,
136
+ "docstring": self.docstring,
137
+ "description": self.description,
138
+ "long_description": self.long_description,
139
+ "bases": self.bases,
140
+ "methods": [m.to_dict() for m in self.methods],
141
+ "attributes": self.attributes,
142
+ "class_attributes": self.class_attributes,
143
+ "decorators": self.decorators,
144
+ "is_dataclass": self.is_dataclass,
145
+ "line_number": self.line_number,
146
+ }
147
+
148
+
149
+ @dataclass
150
+ class ModuleDoc:
151
+ """Documentation for a module.
152
+
153
+ Attributes:
154
+ name: Module name.
155
+ path: File path.
156
+ docstring: Module docstring.
157
+ description: Short description.
158
+ long_description: Full description.
159
+ classes: List of class documentation.
160
+ functions: List of function documentation.
161
+ constants: Module-level constants.
162
+ imports: Import statements.
163
+ """
164
+
165
+ name: str
166
+ path: str = ""
167
+ docstring: str = ""
168
+ description: str = ""
169
+ long_description: str = ""
170
+ classes: list[ClassDoc] = field(default_factory=list)
171
+ functions: list[FunctionDoc] = field(default_factory=list)
172
+ constants: list[dict[str, Any]] = field(default_factory=list)
173
+ imports: list[str] = field(default_factory=list)
174
+
175
+ def to_dict(self) -> dict[str, Any]:
176
+ """Convert to dictionary."""
177
+ return {
178
+ "name": self.name,
179
+ "path": self.path,
180
+ "docstring": self.docstring,
181
+ "description": self.description,
182
+ "long_description": self.long_description,
183
+ "classes": [c.to_dict() for c in self.classes],
184
+ "functions": [f.to_dict() for f in self.functions],
185
+ "constants": self.constants,
186
+ "imports": self.imports,
187
+ }
188
+
189
+
190
+ class DocstringParser:
191
+ """Parses docstrings in various formats (Google, NumPy, reStructuredText)."""
192
+
193
+ # Patterns for Google-style docstrings
194
+ GOOGLE_SECTIONS = {
195
+ "args": r"Args?:\s*\n",
196
+ "arguments": r"Arguments?:\s*\n",
197
+ "parameters": r"Parameters?:\s*\n",
198
+ "returns": r"Returns?:\s*\n",
199
+ "yields": r"Yields?:\s*\n",
200
+ "raises": r"Raises?:\s*\n",
201
+ "examples": r"Examples?:\s*\n",
202
+ "attributes": r"Attributes?:\s*\n",
203
+ "note": r"Note:\s*\n",
204
+ "notes": r"Notes?:\s*\n",
205
+ "warning": r"Warning:\s*\n",
206
+ "see also": r"See Also:\s*\n",
207
+ }
208
+
209
+ @classmethod
210
+ def parse(cls, docstring: str) -> dict[str, Any]:
211
+ """Parse a docstring.
212
+
213
+ Args:
214
+ docstring: Docstring to parse.
215
+
216
+ Returns:
217
+ Parsed docstring sections.
218
+ """
219
+ if not docstring:
220
+ return {}
221
+
222
+ result: dict[str, Any] = {
223
+ "description": "",
224
+ "long_description": "",
225
+ "params": [],
226
+ "returns": {},
227
+ "raises": [],
228
+ "examples": [],
229
+ "attributes": [],
230
+ }
231
+
232
+ lines = docstring.strip().split("\n")
233
+ if not lines:
234
+ return result
235
+
236
+ # First non-empty line is short description
237
+ for i, line in enumerate(lines):
238
+ line = line.strip()
239
+ if line:
240
+ result["description"] = line
241
+ lines = lines[i + 1:]
242
+ break
243
+
244
+ # Parse remaining content
245
+ content = "\n".join(lines)
246
+
247
+ # Extract sections
248
+ sections = cls._split_sections(content)
249
+
250
+ if "description" in sections:
251
+ result["long_description"] = sections["description"].strip()
252
+
253
+ if "args" in sections or "arguments" in sections or "parameters" in sections:
254
+ section = sections.get("args") or sections.get("arguments") or sections.get("parameters", "")
255
+ result["params"] = cls._parse_params(section)
256
+
257
+ if "returns" in sections:
258
+ result["returns"] = cls._parse_returns(sections["returns"])
259
+
260
+ if "raises" in sections:
261
+ result["raises"] = cls._parse_raises(sections["raises"])
262
+
263
+ if "examples" in sections:
264
+ result["examples"] = cls._parse_examples(sections["examples"])
265
+
266
+ if "attributes" in sections:
267
+ result["attributes"] = cls._parse_params(sections["attributes"])
268
+
269
+ return result
270
+
271
+ @classmethod
272
+ def _split_sections(cls, content: str) -> dict[str, str]:
273
+ """Split docstring into sections."""
274
+ sections: dict[str, str] = {"description": ""}
275
+
276
+ # Find all section headers
277
+ section_pattern = r"^(Args?|Arguments?|Parameters?|Returns?|Yields?|Raises?|Examples?|Attributes?|Notes?|Warning|See Also):\s*$"
278
+ current_section = "description"
279
+ current_content: list[str] = []
280
+
281
+ for line in content.split("\n"):
282
+ match = re.match(section_pattern, line.strip(), re.IGNORECASE)
283
+ if match:
284
+ # Save previous section
285
+ sections[current_section] = "\n".join(current_content)
286
+ # Start new section
287
+ current_section = match.group(1).lower()
288
+ if current_section.endswith("s") and current_section not in ["notes", "yields", "raises"]:
289
+ current_section = current_section[:-1]
290
+ current_content = []
291
+ else:
292
+ current_content.append(line)
293
+
294
+ # Save last section
295
+ sections[current_section] = "\n".join(current_content)
296
+
297
+ return sections
298
+
299
+ @classmethod
300
+ def _parse_params(cls, section: str) -> list[dict[str, str]]:
301
+ """Parse parameter section."""
302
+ params = []
303
+ current_param: dict[str, str] | None = None
304
+
305
+ for line in section.split("\n"):
306
+ # Match parameter line: "name (type): description" or "name: description"
307
+ match = re.match(r"^\s*(\w+)(?:\s*\(([^)]+)\))?:\s*(.*)$", line)
308
+ if match:
309
+ if current_param:
310
+ params.append(current_param)
311
+ current_param = {
312
+ "name": match.group(1),
313
+ "type": match.group(2) or "",
314
+ "description": match.group(3),
315
+ }
316
+ elif current_param and line.strip():
317
+ # Continuation of description
318
+ current_param["description"] += " " + line.strip()
319
+
320
+ if current_param:
321
+ params.append(current_param)
322
+
323
+ return params
324
+
325
+ @classmethod
326
+ def _parse_returns(cls, section: str) -> dict[str, str]:
327
+ """Parse returns section."""
328
+ lines = [l.strip() for l in section.split("\n") if l.strip()]
329
+ if not lines:
330
+ return {}
331
+
332
+ # Try to parse "type: description" format
333
+ match = re.match(r"^([^:]+):\s*(.*)$", lines[0])
334
+ if match:
335
+ return {
336
+ "type": match.group(1).strip(),
337
+ "description": match.group(2) + " ".join(lines[1:]),
338
+ }
339
+
340
+ return {"description": " ".join(lines)}
341
+
342
+ @classmethod
343
+ def _parse_raises(cls, section: str) -> list[dict[str, str]]:
344
+ """Parse raises section."""
345
+ raises = []
346
+ current: dict[str, str] | None = None
347
+
348
+ for line in section.split("\n"):
349
+ match = re.match(r"^\s*(\w+):\s*(.*)$", line)
350
+ if match:
351
+ if current:
352
+ raises.append(current)
353
+ current = {
354
+ "exception": match.group(1),
355
+ "description": match.group(2),
356
+ }
357
+ elif current and line.strip():
358
+ current["description"] += " " + line.strip()
359
+
360
+ if current:
361
+ raises.append(current)
362
+
363
+ return raises
364
+
365
+ @classmethod
366
+ def _parse_examples(cls, section: str) -> list[str]:
367
+ """Parse examples section."""
368
+ examples = []
369
+ current: list[str] = []
370
+ in_code_block = False
371
+
372
+ for line in section.split("\n"):
373
+ if line.strip().startswith(">>>") or in_code_block:
374
+ in_code_block = True
375
+ current.append(line)
376
+ if not line.strip() and current:
377
+ examples.append("\n".join(current))
378
+ current = []
379
+ in_code_block = False
380
+ elif current:
381
+ examples.append("\n".join(current))
382
+ current = []
383
+ in_code_block = False
384
+
385
+ if current:
386
+ examples.append("\n".join(current))
387
+
388
+ return examples
389
+
390
+
391
+ class DocumentationExtractor:
392
+ """Extracts documentation from Python source code using AST."""
393
+
394
+ def __init__(self, include_private: bool = False) -> None:
395
+ """Initialize the extractor.
396
+
397
+ Args:
398
+ include_private: Whether to include private members.
399
+ """
400
+ self.include_private = include_private
401
+
402
+ def extract_from_source(self, source: str, module_name: str = "") -> ModuleDoc:
403
+ """Extract documentation from source code.
404
+
405
+ Args:
406
+ source: Python source code.
407
+ module_name: Module name.
408
+
409
+ Returns:
410
+ ModuleDoc with extracted documentation.
411
+ """
412
+ try:
413
+ tree = ast.parse(source)
414
+ except SyntaxError:
415
+ return ModuleDoc(name=module_name)
416
+
417
+ return self._extract_module(tree, module_name)
418
+
419
+ def extract_from_file(self, path: str | Path) -> ModuleDoc:
420
+ """Extract documentation from a file.
421
+
422
+ Args:
423
+ path: Path to Python file.
424
+
425
+ Returns:
426
+ ModuleDoc with extracted documentation.
427
+ """
428
+ path = Path(path)
429
+ source = path.read_text(encoding="utf-8")
430
+ module_name = path.stem
431
+ doc = self.extract_from_source(source, module_name)
432
+ doc.path = str(path)
433
+ return doc
434
+
435
+ def extract_from_path(self, path: str | Path) -> list[ModuleDoc]:
436
+ """Extract documentation from all Python files in a path.
437
+
438
+ Args:
439
+ path: Directory or file path.
440
+
441
+ Returns:
442
+ List of ModuleDoc for each file.
443
+ """
444
+ path = Path(path)
445
+ docs: list[ModuleDoc] = []
446
+
447
+ if path.is_file():
448
+ docs.append(self.extract_from_file(path))
449
+ elif path.is_dir():
450
+ for py_file in path.rglob("*.py"):
451
+ if py_file.name.startswith("_") and not self.include_private:
452
+ continue
453
+ docs.append(self.extract_from_file(py_file))
454
+
455
+ return docs
456
+
457
+ def _extract_module(self, tree: ast.Module, name: str) -> ModuleDoc:
458
+ """Extract module documentation from AST."""
459
+ doc = ModuleDoc(name=name)
460
+
461
+ # Get module docstring
462
+ if (
463
+ tree.body
464
+ and isinstance(tree.body[0], ast.Expr)
465
+ and isinstance(tree.body[0].value, ast.Constant)
466
+ and isinstance(tree.body[0].value.value, str)
467
+ ):
468
+ doc.docstring = tree.body[0].value.value
469
+ parsed = DocstringParser.parse(doc.docstring)
470
+ doc.description = parsed.get("description", "")
471
+ doc.long_description = parsed.get("long_description", "")
472
+
473
+ # Extract imports
474
+ for node in ast.walk(tree):
475
+ if isinstance(node, ast.Import):
476
+ for alias in node.names:
477
+ doc.imports.append(alias.name)
478
+ elif isinstance(node, ast.ImportFrom):
479
+ if node.module:
480
+ doc.imports.append(f"from {node.module}")
481
+
482
+ # Extract classes and functions at module level
483
+ for node in tree.body:
484
+ if isinstance(node, ast.ClassDef):
485
+ if self._should_include(node.name):
486
+ doc.classes.append(self._extract_class(node))
487
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
488
+ if self._should_include(node.name):
489
+ doc.functions.append(self._extract_function(node))
490
+ elif isinstance(node, ast.Assign):
491
+ # Module-level constants
492
+ for target in node.targets:
493
+ if isinstance(target, ast.Name) and target.id.isupper():
494
+ doc.constants.append({
495
+ "name": target.id,
496
+ "value": ast.unparse(node.value) if hasattr(ast, "unparse") else "",
497
+ })
498
+
499
+ return doc
500
+
501
+ def _extract_class(self, node: ast.ClassDef) -> ClassDoc:
502
+ """Extract class documentation from AST."""
503
+ doc = ClassDoc(
504
+ name=node.name,
505
+ line_number=node.lineno,
506
+ )
507
+
508
+ # Get base classes
509
+ for base in node.bases:
510
+ if isinstance(base, ast.Name):
511
+ doc.bases.append(base.id)
512
+ elif isinstance(base, ast.Attribute):
513
+ doc.bases.append(f"{ast.unparse(base)}" if hasattr(ast, "unparse") else base.attr)
514
+
515
+ # Get decorators
516
+ for decorator in node.decorator_list:
517
+ if isinstance(decorator, ast.Name):
518
+ doc.decorators.append(decorator.id)
519
+ if decorator.id == "dataclass":
520
+ doc.is_dataclass = True
521
+ elif isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name):
522
+ doc.decorators.append(decorator.func.id)
523
+ if decorator.func.id == "dataclass":
524
+ doc.is_dataclass = True
525
+
526
+ # Get docstring
527
+ if (
528
+ node.body
529
+ and isinstance(node.body[0], ast.Expr)
530
+ and isinstance(node.body[0].value, ast.Constant)
531
+ and isinstance(node.body[0].value.value, str)
532
+ ):
533
+ doc.docstring = node.body[0].value.value
534
+ parsed = DocstringParser.parse(doc.docstring)
535
+ doc.description = parsed.get("description", "")
536
+ doc.long_description = parsed.get("long_description", "")
537
+ doc.attributes = parsed.get("attributes", [])
538
+
539
+ # Extract methods
540
+ for item in node.body:
541
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
542
+ if self._should_include(item.name):
543
+ doc.methods.append(self._extract_function(item))
544
+ elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
545
+ # Class attribute with type annotation
546
+ doc.class_attributes.append({
547
+ "name": item.target.id,
548
+ "type": ast.unparse(item.annotation) if hasattr(ast, "unparse") else "",
549
+ "value": ast.unparse(item.value) if item.value and hasattr(ast, "unparse") else None,
550
+ })
551
+
552
+ return doc
553
+
554
+ def _extract_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionDoc:
555
+ """Extract function documentation from AST."""
556
+ doc = FunctionDoc(
557
+ name=node.name,
558
+ is_async=isinstance(node, ast.AsyncFunctionDef),
559
+ line_number=node.lineno,
560
+ )
561
+
562
+ # Get decorators
563
+ for decorator in node.decorator_list:
564
+ if isinstance(decorator, ast.Name):
565
+ doc.decorators.append(decorator.id)
566
+ if decorator.id == "classmethod":
567
+ doc.is_classmethod = True
568
+ elif decorator.id == "staticmethod":
569
+ doc.is_staticmethod = True
570
+ elif isinstance(decorator, ast.Attribute):
571
+ doc.decorators.append(decorator.attr)
572
+
573
+ # Build signature
574
+ doc.signature = self._build_signature(node)
575
+
576
+ # Get docstring
577
+ if (
578
+ node.body
579
+ and isinstance(node.body[0], ast.Expr)
580
+ and isinstance(node.body[0].value, ast.Constant)
581
+ and isinstance(node.body[0].value.value, str)
582
+ ):
583
+ doc.docstring = node.body[0].value.value
584
+ parsed = DocstringParser.parse(doc.docstring)
585
+ doc.description = parsed.get("description", "")
586
+ doc.long_description = parsed.get("long_description", "")
587
+ doc.returns = parsed.get("returns", {})
588
+ doc.raises = parsed.get("raises", [])
589
+ doc.examples = parsed.get("examples", [])
590
+
591
+ # Match docstring params to function params
592
+ docstring_params = {p["name"]: p for p in parsed.get("params", [])}
593
+ doc.parameters = self._extract_parameters(node.args, docstring_params)
594
+ else:
595
+ doc.parameters = self._extract_parameters(node.args, {})
596
+
597
+ return doc
598
+
599
+ def _extract_parameters(
600
+ self,
601
+ args: ast.arguments,
602
+ docstring_params: dict[str, dict[str, str]],
603
+ ) -> list[ParameterDoc]:
604
+ """Extract function parameters from AST."""
605
+ params: list[ParameterDoc] = []
606
+
607
+ # Calculate defaults offset
608
+ num_defaults = len(args.defaults)
609
+ num_args = len(args.args)
610
+ defaults_offset = num_args - num_defaults
611
+
612
+ for i, arg in enumerate(args.args):
613
+ if arg.arg == "self" or arg.arg == "cls":
614
+ continue
615
+
616
+ param = ParameterDoc(name=arg.arg)
617
+
618
+ # Type hint
619
+ if arg.annotation:
620
+ param.type_hint = ast.unparse(arg.annotation) if hasattr(ast, "unparse") else ""
621
+
622
+ # Default value
623
+ default_index = i - defaults_offset
624
+ if default_index >= 0:
625
+ param.default = ast.unparse(args.defaults[default_index]) if hasattr(ast, "unparse") else "..."
626
+ param.required = False
627
+
628
+ # Description from docstring
629
+ if arg.arg in docstring_params:
630
+ param.description = docstring_params[arg.arg].get("description", "")
631
+ if not param.type_hint:
632
+ param.type_hint = docstring_params[arg.arg].get("type", "")
633
+
634
+ params.append(param)
635
+
636
+ # Handle *args and **kwargs
637
+ if args.vararg:
638
+ params.append(ParameterDoc(
639
+ name=f"*{args.vararg.arg}",
640
+ type_hint=ast.unparse(args.vararg.annotation) if args.vararg.annotation and hasattr(ast, "unparse") else "",
641
+ required=False,
642
+ ))
643
+
644
+ if args.kwarg:
645
+ params.append(ParameterDoc(
646
+ name=f"**{args.kwarg.arg}",
647
+ type_hint=ast.unparse(args.kwarg.annotation) if args.kwarg.annotation and hasattr(ast, "unparse") else "",
648
+ required=False,
649
+ ))
650
+
651
+ return params
652
+
653
+ def _build_signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
654
+ """Build function signature string."""
655
+ parts = []
656
+
657
+ if isinstance(node, ast.AsyncFunctionDef):
658
+ parts.append("async ")
659
+
660
+ parts.append(f"def {node.name}(")
661
+
662
+ # Parameters
663
+ param_strs = []
664
+ args = node.args
665
+
666
+ for i, arg in enumerate(args.args):
667
+ param = arg.arg
668
+ if arg.annotation:
669
+ param += f": {ast.unparse(arg.annotation)}" if hasattr(ast, "unparse") else ""
670
+
671
+ # Default
672
+ default_index = i - (len(args.args) - len(args.defaults))
673
+ if default_index >= 0:
674
+ param += f" = {ast.unparse(args.defaults[default_index])}" if hasattr(ast, "unparse") else " = ..."
675
+
676
+ param_strs.append(param)
677
+
678
+ if args.vararg:
679
+ param = f"*{args.vararg.arg}"
680
+ if args.vararg.annotation:
681
+ param += f": {ast.unparse(args.vararg.annotation)}" if hasattr(ast, "unparse") else ""
682
+ param_strs.append(param)
683
+
684
+ if args.kwarg:
685
+ param = f"**{args.kwarg.arg}"
686
+ if args.kwarg.annotation:
687
+ param += f": {ast.unparse(args.kwarg.annotation)}" if hasattr(ast, "unparse") else ""
688
+ param_strs.append(param)
689
+
690
+ parts.append(", ".join(param_strs))
691
+ parts.append(")")
692
+
693
+ # Return type
694
+ if node.returns:
695
+ parts.append(f" -> {ast.unparse(node.returns)}" if hasattr(ast, "unparse") else "")
696
+
697
+ return "".join(parts)
698
+
699
+ def _should_include(self, name: str) -> bool:
700
+ """Check if member should be included."""
701
+ if name.startswith("_") and not self.include_private:
702
+ return False
703
+ return True