sphinx-mkdocs-migrate 0.0.1.dev0__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 (39) hide show
  1. sphinx_mkdocs_migrate/__init__.py +8 -0
  2. sphinx_mkdocs_migrate/analyzer/__init__.py +17 -0
  3. sphinx_mkdocs_migrate/analyzer/ci.py +223 -0
  4. sphinx_mkdocs_migrate/analyzer/dependencies.py +134 -0
  5. sphinx_mkdocs_migrate/analyzer/markdown.py +148 -0
  6. sphinx_mkdocs_migrate/analyzer/mkdocs.py +263 -0
  7. sphinx_mkdocs_migrate/analyzer/models.py +348 -0
  8. sphinx_mkdocs_migrate/analyzer/navigation.py +108 -0
  9. sphinx_mkdocs_migrate/analyzer/project.py +511 -0
  10. sphinx_mkdocs_migrate/cli.py +507 -0
  11. sphinx_mkdocs_migrate/parsing/doc_ir.py +533 -0
  12. sphinx_mkdocs_migrate/parsing/flow_extractor.py +457 -0
  13. sphinx_mkdocs_migrate/parsing/html_flow_parser.py +349 -0
  14. sphinx_mkdocs_migrate/parsing/markdown.py +22 -0
  15. sphinx_mkdocs_migrate/parsing/markdown_ir.py +49 -0
  16. sphinx_mkdocs_migrate/parsing/markdown_it_adapter.py +496 -0
  17. sphinx_mkdocs_migrate/parsing/requirements.py +155 -0
  18. sphinx_mkdocs_migrate/planner/accountability.py +111 -0
  19. sphinx_mkdocs_migrate/planner/ci.py +142 -0
  20. sphinx_mkdocs_migrate/planner/conf_builder.py +183 -0
  21. sphinx_mkdocs_migrate/planner/models.py +379 -0
  22. sphinx_mkdocs_migrate/planner/planner.py +1867 -0
  23. sphinx_mkdocs_migrate/planner/policy.py +474 -0
  24. sphinx_mkdocs_migrate/planner/theme_constants.py +70 -0
  25. sphinx_mkdocs_migrate/planner/toctree.py +158 -0
  26. sphinx_mkdocs_migrate/py.typed +1 -0
  27. sphinx_mkdocs_migrate/rules/catalog.py +154 -0
  28. sphinx_mkdocs_migrate/rules/engine.py +94 -0
  29. sphinx_mkdocs_migrate/rules/models.py +176 -0
  30. sphinx_mkdocs_migrate/transformer/engine.py +897 -0
  31. sphinx_mkdocs_migrate/transformer/models.py +59 -0
  32. sphinx_mkdocs_migrate/transformer/myst_transformer.py +393 -0
  33. sphinx_mkdocs_migrate/validator/models.py +40 -0
  34. sphinx_mkdocs_migrate/validator/verifier.py +377 -0
  35. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/METADATA +199 -0
  36. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/RECORD +39 -0
  37. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/WHEEL +4 -0
  38. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  39. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,59 @@
1
+ """Data models for deterministic, plan-driven document transformations and patch reporting."""
2
+
3
+ from enum import Enum
4
+ from typing import List, Dict, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class TransformationStatus(str, Enum):
9
+ APPLIED = "APPLIED" # Successfully transformed targeted construct spans
10
+ UNCHANGED = "UNCHANGED" # No transform actions; byte-for-byte source preserved
11
+ MANUAL_REQUIRED = "MANUAL_REQUIRED" # Document contains manual action items (retained original source)
12
+ SKIPPED = "SKIPPED" # Plan mismatch or skipped document
13
+
14
+
15
+ class ConfPyStatus(str, Enum):
16
+ CREATED = "CREATED" # conf.py did not exist and was generated
17
+ UNCHANGED = "UNCHANGED" # Existing conf.py was already identical to generated plan
18
+ CONFLICT = "CONFLICT" # Existing conf.py differs; skipped to prevent overwrite without --force
19
+
20
+
21
+ class DocumentTransformationResult(BaseModel):
22
+ """Result of transforming a single Markdown source document via plan-driven source-span patching."""
23
+
24
+ source_file: str
25
+ target_file: str
26
+ original_content: str
27
+ transformed_content: str
28
+ source_fingerprint: str
29
+ transforms_applied: int = 0
30
+ constructs_preserved: int = 0
31
+ manual_items_reported: int = 0
32
+ unsupported_items_reported: int = 0
33
+ stale_actions_count: int = 0
34
+ stale_action_details: List[str] = Field(default_factory=list)
35
+ status: TransformationStatus = TransformationStatus.APPLIED
36
+ diff: Optional[str] = None
37
+ is_modified: bool = False
38
+
39
+
40
+ class ProjectTransformationReport(BaseModel):
41
+ """Aggregate result of executing a MigrationPlan across the entire repository."""
42
+
43
+ project_root: str
44
+ plan_hash: str
45
+ transformed_documents: List[DocumentTransformationResult] = Field(
46
+ default_factory=list
47
+ )
48
+ generated_sphinx_files: Dict[str, str] = Field(
49
+ default_factory=dict
50
+ ) # e.g. "docs/conf.py"
51
+ conf_py_status: ConfPyStatus = ConfPyStatus.CREATED
52
+ conf_py_conflict_diff: Optional[str] = None
53
+ documents_examined: int = 0
54
+ documents_changed: int = 0
55
+ files_written_to_disk: int = 0
56
+ total_transforms_executed: int = 0
57
+ total_stale_actions: int = 0
58
+ cleaned_files: List[str] = Field(default_factory=list)
59
+ dry_run: bool = True
@@ -0,0 +1,393 @@
1
+ """Source-preserving patch renderer transforming only targeted construct spans with safe recursive fence nesting and disjoint span guarantees."""
2
+
3
+ import re
4
+ import hashlib
5
+ from typing import List, Dict, Tuple
6
+ from ..parsing.markdown_ir import BaseIRNode, NodeKind, DocumentIR
7
+ from ..rules.models import MigrationAction
8
+ from ..analyzer.models import Classification
9
+
10
+
11
+ class StalePlanException(Exception):
12
+ """Raised when source span fingerprint does not match the planned action fingerprint."""
13
+
14
+ pass
15
+
16
+
17
+ class OverlappingSpanException(Exception):
18
+ """Raised when transformation plan contains unauthorized overlapping spans that are not parent-owned."""
19
+
20
+ pass
21
+
22
+
23
+ class MySTDocumentTransformer:
24
+ """Transforms targeted DocumentIR spans into valid MyST Markdown using source-preserving patching,
25
+ fingerprint stale-plan validation, safe recursive fence allocation, and disjoint parent ownership checks."""
26
+
27
+ def __init__(
28
+ self, actions: List[MigrationAction], strict_fingerprint: bool = False
29
+ ):
30
+ self.actions = actions
31
+ self.actions_by_span: Dict[Tuple[int, int], MigrationAction] = {
32
+ (a.start_line, a.end_line): a for a in actions
33
+ }
34
+ self.strict_fingerprint = strict_fingerprint
35
+
36
+ def transform_document(
37
+ self, doc_ir: DocumentIR, raw_source: str
38
+ ) -> Tuple[str, int, int, int, int, int, List[str]]:
39
+ """Source-preserving transformation:
40
+ - Validates source span fingerprints against planned actions.
41
+ - Verifies disjoint span invariants on root replacement spans.
42
+ - Leaves untouched lines completely byte-for-byte identical.
43
+ - Replaces only the outermost [start_line, end_line] slices for TRANSFORM actions.
44
+ - Preserves original source for PRESERVE, MANUAL, and UNSUPPORTED actions.
45
+ Returns: (transformed_text, transforms_applied, constructs_preserved, manual_reported, unsupported_reported, stale_actions_count, stale_details)
46
+ """
47
+ # 1. Enforce Disjoint Spans Invariant across all TRANSFORM actions in the plan
48
+ transform_actions = [
49
+ a for a in self.actions if a.classification == Classification.TRANSFORM
50
+ ]
51
+ sorted_transforms = sorted(
52
+ transform_actions, key=lambda a: (a.start_line, -a.end_line)
53
+ )
54
+ for i in range(len(sorted_transforms) - 1):
55
+ curr_a = sorted_transforms[i]
56
+ next_a = sorted_transforms[i + 1]
57
+ # If next start line is before curr end line, it must be strictly contained or it's an invalid partial overlap
58
+ if next_a.start_line <= curr_a.end_line:
59
+ if next_a.end_line > curr_a.end_line:
60
+ raise OverlappingSpanException(
61
+ f"Partial overlapping transformation spans detected between action '{curr_a.action_id}' "
62
+ f"[{curr_a.start_line}, {curr_a.end_line}] and '{next_a.action_id}' [{next_a.start_line}, {next_a.end_line}]."
63
+ )
64
+
65
+ lines = raw_source.splitlines(keepends=True)
66
+ raw_lines_stripped = raw_source.splitlines()
67
+ transforms_applied = 0
68
+ constructs_preserved = 0
69
+ manual_reported = 0
70
+ unsupported_reported = 0
71
+ stale_actions_count = 0
72
+ stale_details: List[str] = []
73
+
74
+ # Collect non-overlapping root-level target spans to replace
75
+ spans_to_replace: List[Tuple[int, int, str]] = []
76
+
77
+ for node in doc_ir.nodes:
78
+ span_key = (node.start_line, node.end_line)
79
+ action = self.actions_by_span.get(span_key)
80
+
81
+ if action is None:
82
+ continue
83
+
84
+ # Action-level Stale-Plan Fingerprint Validation
85
+ if action.source_span_fingerprint and raw_lines_stripped:
86
+ start_l, end_l = node.start_line, node.end_line
87
+ if 1 <= start_l <= len(raw_lines_stripped):
88
+ end_idx = min(end_l, len(raw_lines_stripped))
89
+ current_span_text = "\n".join(
90
+ raw_lines_stripped[start_l - 1 : end_idx]
91
+ )
92
+ current_fp = hashlib.sha256(
93
+ current_span_text.encode("utf-8")
94
+ ).hexdigest()[:16]
95
+ if current_fp != action.source_span_fingerprint:
96
+ stale_msg = (
97
+ f"{action.source_file}:{start_l}-{end_l} STALE_PLAN: "
98
+ f"Expected fingerprint {action.source_span_fingerprint}, got {current_fp}"
99
+ )
100
+ stale_actions_count += 1
101
+ stale_details.append(stale_msg)
102
+ if self.strict_fingerprint:
103
+ raise StalePlanException(stale_msg)
104
+ # Skip stale transformation
105
+ continue
106
+
107
+ if action.classification == Classification.TRANSFORM:
108
+ rendered_myst = self._render_node_safely(node)
109
+ spans_to_replace.append((node.start_line, node.end_line, rendered_myst))
110
+ transforms_applied += 1
111
+ elif action.classification == Classification.PRESERVE:
112
+ constructs_preserved += 1
113
+ elif action.classification == Classification.MANUAL:
114
+ manual_reported += 1
115
+ elif action.classification == Classification.UNSUPPORTED:
116
+ unsupported_reported += 1
117
+
118
+ def _sanitize_fm(text: str) -> str:
119
+ if text.startswith("---"):
120
+ parts = text.split("---", 2)
121
+ if len(parts) >= 3:
122
+ sanitized_header = re.sub(
123
+ r"(\bdate\s*:\s*)(\d{4}-\d{2}-\d{2})\b", r'\1"\2"', parts[1]
124
+ )
125
+ return "---" + sanitized_header + "---" + parts[2]
126
+ return text
127
+
128
+ if not spans_to_replace:
129
+ return (
130
+ _sanitize_fm(raw_source),
131
+ transforms_applied,
132
+ constructs_preserved,
133
+ manual_reported,
134
+ unsupported_reported,
135
+ stale_actions_count,
136
+ stale_details,
137
+ )
138
+
139
+ # Double check disjoint spans on root replacement targets
140
+ spans_to_replace.sort(key=lambda s: s[0])
141
+ for i in range(len(spans_to_replace) - 1):
142
+ curr_s, curr_e, _ = spans_to_replace[i]
143
+ next_s, next_e, _ = spans_to_replace[i + 1]
144
+ if curr_e >= next_s:
145
+ raise OverlappingSpanException(
146
+ f"Overlapping transformation spans detected: [{curr_s}, {curr_e}] and [{next_s}, {next_e}]. "
147
+ f"Parent containers must exclusively own child transformations."
148
+ )
149
+
150
+ output_lines: List[str] = []
151
+ current_line_idx = 1
152
+
153
+ for start_l, end_l, replacement in spans_to_replace:
154
+ while current_line_idx < start_l:
155
+ output_lines.append(lines[current_line_idx - 1])
156
+ current_line_idx += 1
157
+
158
+ if not replacement.endswith("\n"):
159
+ replacement += "\n"
160
+ output_lines.append(replacement)
161
+ current_line_idx = end_l + 1
162
+
163
+ while current_line_idx <= len(lines):
164
+ output_lines.append(lines[current_line_idx - 1])
165
+ current_line_idx += 1
166
+
167
+ return (
168
+ "".join(output_lines),
169
+ transforms_applied,
170
+ constructs_preserved,
171
+ manual_reported,
172
+ unsupported_reported,
173
+ stale_actions_count,
174
+ stale_details,
175
+ )
176
+
177
+ def _get_max_child_fence(self, node: BaseIRNode) -> int:
178
+ """Recursively inspects node subtree to find the maximum existing or needed fence delimiter length."""
179
+ max_len = 0
180
+ for child in node.children:
181
+ if child.kind == NodeKind.CODE_BLOCK:
182
+ max_len = max(max_len, 3)
183
+ elif child.kind in (NodeKind.ADMONITION, NodeKind.DETAILS_DROPDOWN):
184
+ child_inner = self._get_max_child_fence(child)
185
+ child_container_len = max(child_inner + 1, 3)
186
+ max_len = max(max_len, child_container_len)
187
+ elif child.kind == NodeKind.TAB_SET:
188
+ child_inner = self._get_max_child_fence(child)
189
+ max_len = max(max_len, child_inner + 2) # tab-item + tab-set
190
+ else:
191
+ max_len = max(max_len, self._get_max_child_fence(child))
192
+ return max_len
193
+
194
+ def _render_node_safely(self, node: BaseIRNode, min_fence_len: int = 3) -> str:
195
+ """Renders an IR node with strictly valid nested fence lengths:
196
+ Outer fence = max(max_descendant_fence + 1, min_fence_len)
197
+ """
198
+ child_max = self._get_max_child_fence(node)
199
+ fence_len = max(child_max + 1, min_fence_len)
200
+ fence_marker = "`" * fence_len
201
+
202
+ # 1. Admonitions (!!! note "Title")
203
+ if node.kind == NodeKind.ADMONITION:
204
+ adm_type = node.metadata.get("admonition_type", "note")
205
+ title = node.metadata.get("title", "")
206
+
207
+ action = self.actions_by_span.get((node.start_line, node.end_line))
208
+ directive = (
209
+ action.target_directive
210
+ if action and action.target_directive
211
+ else adm_type
212
+ )
213
+
214
+ if directive in (
215
+ "note",
216
+ "warning",
217
+ "tip",
218
+ "important",
219
+ "caution",
220
+ "danger",
221
+ "seealso",
222
+ ):
223
+ header = (
224
+ f"{fence_marker}{{{directive}}} {title}".strip()
225
+ if title != directive.capitalize()
226
+ else f"{fence_marker}{{{directive}}}"
227
+ )
228
+ else:
229
+ header = (
230
+ f"{fence_marker}{{admonition}} {title}\n:class: {adm_type}".strip()
231
+ )
232
+
233
+ body_parts = [
234
+ self._render_child_body(child, parent_fence_len=fence_len)
235
+ for child in node.children
236
+ ]
237
+ body_text = "\n\n".join(b for b in body_parts if b)
238
+ return f"{header}\n{body_text}\n{fence_marker}"
239
+
240
+ # 2. Content Tabs (=== "Title")
241
+ elif node.kind == NodeKind.TAB_SET:
242
+ tab_items_rendered: List[str] = []
243
+
244
+ # Find the maximum fence needed by ANY tab item's content
245
+ max_inner_fence = 0
246
+ for tab_item in node.children:
247
+ max_inner_fence = max(
248
+ max_inner_fence, self._get_max_child_fence(tab_item)
249
+ )
250
+
251
+ # Tab items need (max_inner_fence + 1) or at least 3
252
+ item_fence_len = max(max_inner_fence + 1, 3)
253
+ # Outer tab-set must strictly enclose tab-items: (item_fence_len + 1)
254
+ outer_fence_len = max(item_fence_len + 1, min_fence_len)
255
+
256
+ outer_fence = "`" * outer_fence_len
257
+ item_fence = "`" * item_fence_len
258
+
259
+ for tab_item in node.children:
260
+ tab_title = tab_item.metadata.get("title", "Tab").strip()
261
+ # Strip all inline backticks from tab title for clean sphinx-design label compatibility
262
+ tab_title = tab_title.replace("`", "")
263
+ inner_parts = [
264
+ self._render_child_body(c, parent_fence_len=item_fence_len)
265
+ for c in tab_item.children
266
+ ]
267
+ inner_body = "\n\n".join(b for b in inner_parts if b)
268
+ item_rendered = (
269
+ f"{item_fence}{{tab-item}} {tab_title}\n{inner_body}\n{item_fence}"
270
+ )
271
+ tab_items_rendered.append(item_rendered)
272
+
273
+ all_tabs_text = "\n\n".join(tab_items_rendered)
274
+ return f"{outer_fence}{{tab-set}}\n{all_tabs_text}\n{outer_fence}"
275
+
276
+ # 3. Details Dropdowns (???+ note "Title")
277
+ elif node.kind == NodeKind.DETAILS_DROPDOWN:
278
+ title = node.metadata.get("title", "Details")
279
+ is_open = node.metadata.get("open_state", False)
280
+ open_opt = "\n:open:" if is_open else ""
281
+
282
+ body_parts = [
283
+ self._render_child_body(child, parent_fence_len=fence_len)
284
+ for child in node.children
285
+ ]
286
+ body_text = "\n\n".join(b for b in body_parts if b)
287
+ return f"{fence_marker}{{dropdown}} {title}{open_opt}\n{body_text}\n{fence_marker}"
288
+
289
+ # 4. Snippet Includes (--8<-- "path")
290
+ elif node.kind == NodeKind.SNIPPET_INCLUDE:
291
+ filepath = node.metadata.get("filepath", "")
292
+ directive = (
293
+ "include"
294
+ if (filepath.endswith(".md") or filepath.endswith(".txt"))
295
+ else "literalinclude"
296
+ )
297
+ return f"```{{{directive}}} {filepath}\n```"
298
+
299
+ # 5. Mermaid Diagrams (```mermaid)
300
+ elif node.kind == NodeKind.MERMAID_DIAGRAM:
301
+ diagram_code = node.raw_text.strip()
302
+ if diagram_code.startswith("---"):
303
+ parts = diagram_code.split("---", 2)
304
+ if len(parts) >= 3:
305
+ diagram_code = parts[2].strip()
306
+ return f"```{{mermaid}}\n{diagram_code}\n```"
307
+
308
+ # 6. API Directives (::: symbol)
309
+ elif node.kind == NodeKind.API_DIRECTIVE:
310
+ symbol = node.metadata.get("symbol", "")
311
+ raw_text = node.raw_text
312
+
313
+ explicit_members = []
314
+ is_all_members = False
315
+ for line in raw_text.splitlines():
316
+ s = line.strip()
317
+ if s.startswith(":members:"):
318
+ m_val = s[len(":members:") :].strip()
319
+ if m_val:
320
+ explicit_members = [item for item in m_val.split() if item]
321
+ else:
322
+ is_all_members = True
323
+
324
+ last_part = symbol.split(".")[-1] if symbol else ""
325
+ if "Error" in last_part or "Exception" in last_part:
326
+ directive = "autoexception"
327
+ elif last_part and last_part[0].isupper():
328
+ directive = "autoclass"
329
+ elif "_" in last_part or (last_part and last_part.islower()):
330
+ directive = "autofunction"
331
+ else:
332
+ directive = "autoclass"
333
+
334
+ lines = [f".. {directive}:: {symbol}"]
335
+ if explicit_members:
336
+ lines.append(f" :members: {', '.join(explicit_members)}")
337
+ elif is_all_members:
338
+ lines.append(" :members:")
339
+
340
+ if directive in ("autoclass", "autoexception"):
341
+ lines.append(" :show-inheritance:")
342
+
343
+ rst_block = "\n".join(lines)
344
+ return f"```{{eval-rst}}\n{rst_block}\n```"
345
+
346
+ return node.raw_text
347
+
348
+ def _render_child_body(self, node: BaseIRNode, parent_fence_len: int) -> str:
349
+ """Renders a child node inside a parent container directive with strictly safe fence lengths."""
350
+ if node.kind in (
351
+ NodeKind.ADMONITION,
352
+ NodeKind.TAB_SET,
353
+ NodeKind.DETAILS_DROPDOWN,
354
+ ):
355
+ return self._render_node_safely(
356
+ node, min_fence_len=parent_fence_len - 1 if parent_fence_len > 3 else 3
357
+ )
358
+ elif node.kind == NodeKind.CODE_BLOCK:
359
+ info = node.metadata.get("info_string", "")
360
+ code_text = node.raw_text
361
+ if not code_text.endswith("\n"):
362
+ code_text += "\n"
363
+ # Code block inside a parent container of length N must use < N backticks (typically 3)
364
+ code_fence_len = min(3, parent_fence_len - 1) if parent_fence_len > 3 else 3
365
+ code_fence = "`" * code_fence_len
366
+ return f"{code_fence}{info}\n{code_text}{code_fence}"
367
+ elif node.kind == NodeKind.PARAGRAPH:
368
+ return node.raw_text
369
+ elif node.kind == NodeKind.HEADING:
370
+ level = node.metadata.get("level", 1)
371
+ title = node.metadata.get("title", node.raw_text)
372
+ return f"{'#' * level} {title}"
373
+ elif node.kind == NodeKind.LIST:
374
+ items_text = [
375
+ self._render_child_body(item, parent_fence_len)
376
+ for item in node.children
377
+ ]
378
+ return "\n".join(items_text)
379
+ elif node.kind == NodeKind.LIST_ITEM:
380
+ child_text = (
381
+ "\n".join(
382
+ self._render_child_body(c, parent_fence_len) for c in node.children
383
+ )
384
+ if node.children
385
+ else node.raw_text
386
+ )
387
+ lines = child_text.splitlines()
388
+ if not lines:
389
+ return "- "
390
+ first_line = f"- {lines[0]}"
391
+ rest_lines = [f" {line_item}" for line_item in lines[1:]]
392
+ return "\n".join([first_line] + rest_lines)
393
+ return node.raw_text
@@ -0,0 +1,40 @@
1
+ """Data models for post-transformation validation and Sphinx build verification."""
2
+
3
+ from enum import Enum
4
+ from typing import List, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ValidationSeverity(str, Enum):
9
+ INFO = "INFO"
10
+ WARNING = "WARNING"
11
+ ERROR = "ERROR"
12
+
13
+
14
+ class ValidationIssue(BaseModel):
15
+ """Validation finding reported during structural parsing or Sphinx build execution."""
16
+
17
+ file_path: str
18
+ line_number: Optional[int] = None
19
+ severity: ValidationSeverity
20
+ issue_type: str # e.g. "UNCLOSED_FENCE", "STALE_PLAN", "SPHINX_BUILD_ERROR", "SPHINX_BUILD_WARNING"
21
+ message: str
22
+ context_snippet: Optional[str] = None
23
+
24
+
25
+ class ValidationReport(BaseModel):
26
+ """Overall validation report checking CommonMark structural validity, Sphinx config, and build execution."""
27
+
28
+ passed: bool
29
+ total_issues: int = 0
30
+ errors_count: int = 0
31
+ warnings_count: int = 0
32
+ issues: List[ValidationIssue] = Field(default_factory=list)
33
+ commonmark_parse_successful: bool = False
34
+ structural_validation_successful: bool = False
35
+ sphinx_build_attempted: bool = False
36
+ sphinx_build_successful: Optional[bool] = None
37
+ sphinx_warning_count: int = 0
38
+ sphinx_warnings: List[str] = Field(default_factory=list)
39
+ sphinx_theme_status: Optional[str] = None
40
+ build_output: Optional[str] = None