sphinx-mkdocs-migrate 0.0.1.dev0__tar.gz → 0.0.1.dev1__tar.gz

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 (42) hide show
  1. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/PKG-INFO +6 -5
  2. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/README.md +4 -3
  3. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/pyproject.toml +1 -1
  4. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/__init__.py +1 -1
  5. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/mkdocs.py +51 -10
  6. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/models.py +4 -0
  7. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/navigation.py +30 -2
  8. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/cli.py +342 -6
  9. sphinx_mkdocs_migrate-0.0.1.dev1/src/sphinx_mkdocs_migrate/constants.py +5 -0
  10. sphinx_mkdocs_migrate-0.0.1.dev1/src/sphinx_mkdocs_migrate/parsing/markdown.py +47 -0
  11. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/markdown_it_adapter.py +11 -1
  12. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/conf_builder.py +13 -0
  13. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/models.py +1 -0
  14. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/planner.py +191 -8
  15. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/toctree.py +9 -6
  16. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/engine.py +263 -26
  17. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/myst_transformer.py +23 -3
  18. sphinx_mkdocs_migrate-0.0.1.dev0/src/sphinx_mkdocs_migrate/parsing/markdown.py +0 -22
  19. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/.gitignore +0 -0
  20. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/LICENSE +0 -0
  21. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/MIGRATION_GUIDE.md +0 -0
  22. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/__init__.py +0 -0
  23. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/ci.py +0 -0
  24. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/dependencies.py +0 -0
  25. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/markdown.py +0 -0
  26. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/project.py +0 -0
  27. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/doc_ir.py +0 -0
  28. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/flow_extractor.py +0 -0
  29. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/html_flow_parser.py +0 -0
  30. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/markdown_ir.py +0 -0
  31. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/requirements.py +0 -0
  32. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/accountability.py +0 -0
  33. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/ci.py +0 -0
  34. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/policy.py +0 -0
  35. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/theme_constants.py +0 -0
  36. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/py.typed +0 -0
  37. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/catalog.py +0 -0
  38. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/engine.py +0 -0
  39. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/models.py +0 -0
  40. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/models.py +0 -0
  41. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/validator/models.py +0 -0
  42. {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/validator/verifier.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sphinx-mkdocs-migrate
3
- Version: 0.0.1.dev0
3
+ Version: 0.0.1.dev1
4
4
  Summary: Deterministic, evidence-driven analyzer and migration engine from MkDocs to Sphinx + MyST
5
5
  Project-URL: Homepage, https://github.com/prateek-dagar/sphinx-mkdocs-migrate
6
- Project-URL: Documentation, https://github.com/prateek-dagar/sphinx-mkdocs-migrate#readme
6
+ Project-URL: Documentation, https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/
7
7
  Project-URL: Repository, https://github.com/prateek-dagar/sphinx-mkdocs-migrate.git
8
8
  Project-URL: Issues, https://github.com/prateek-dagar/sphinx-mkdocs-migrate/issues
9
9
  Project-URL: Changelog, https://github.com/prateek-dagar/sphinx-mkdocs-migrate/blob/main/CHANGELOG.md
@@ -74,6 +74,7 @@ Description-Content-Type: text/markdown
74
74
  # sphinx_mkdocs_migrate
75
75
 
76
76
  [![PyPI version](https://img.shields.io/badge/pypi-0.0.1.dev0-blue.svg)](https://pypi.org/project/sphinx-mkdocs-migrate/)
77
+ [![Documentation Status](https://readthedocs.org/projects/sphinx-mkdocs-migrate/badge/?version=latest)](https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/?badge=latest)
77
78
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
78
79
 
79
80
  **`sphinx-mkdocs-migrate`** (`sphinx-migrate`) is a deterministic, evidence-driven analyzer and migration engine that safely converts MkDocs and Material for MkDocs documentation projects to Sphinx + MyST Parser.
@@ -111,11 +112,11 @@ The migration lifecycle consists of 4 distinct commands:
111
112
 
112
113
  ```text
113
114
  sphinx-migrate analyze # 1. Factual project & subsystem inspection
114
- ↑
115
+ ↓
115
116
  sphinx-migrate plan # 2. Deterministic, read-only MigrationPlan generation
116
- ⊑
117
+ ↓
117
118
  sphinx-migrate migrate # 3. Dry-run diffing or atomic disk transformation
118
- ⊑
119
+ ↓
119
120
  sphinx-migrate validate # 4. AST validation & isolated sandbox Sphinx build
120
121
  ```
121
122
 
@@ -1,6 +1,7 @@
1
1
  # sphinx_mkdocs_migrate
2
2
 
3
3
  [![PyPI version](https://img.shields.io/badge/pypi-0.0.1.dev0-blue.svg)](https://pypi.org/project/sphinx-mkdocs-migrate/)
4
+ [![Documentation Status](https://readthedocs.org/projects/sphinx-mkdocs-migrate/badge/?version=latest)](https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/?badge=latest)
4
5
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
5
6
 
6
7
  **`sphinx-mkdocs-migrate`** (`sphinx-migrate`) is a deterministic, evidence-driven analyzer and migration engine that safely converts MkDocs and Material for MkDocs documentation projects to Sphinx + MyST Parser.
@@ -38,11 +39,11 @@ The migration lifecycle consists of 4 distinct commands:
38
39
 
39
40
  ```text
40
41
  sphinx-migrate analyze # 1. Factual project & subsystem inspection
41
- ↑
42
+ ↓
42
43
  sphinx-migrate plan # 2. Deterministic, read-only MigrationPlan generation
43
- ⊑
44
+ ↓
44
45
  sphinx-migrate migrate # 3. Dry-run diffing or atomic disk transformation
45
- ⊑
46
+ ↓
46
47
  sphinx-migrate validate # 4. AST validation & isolated sandbox Sphinx build
47
48
  ```
48
49
 
@@ -56,7 +56,7 @@ dependencies = [
56
56
 
57
57
  [project.urls]
58
58
  Homepage = "https://github.com/prateek-dagar/sphinx-mkdocs-migrate"
59
- Documentation = "https://github.com/prateek-dagar/sphinx-mkdocs-migrate#readme"
59
+ Documentation = "https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/"
60
60
  Repository = "https://github.com/prateek-dagar/sphinx-mkdocs-migrate.git"
61
61
  Issues = "https://github.com/prateek-dagar/sphinx-mkdocs-migrate/issues"
62
62
  Changelog = "https://github.com/prateek-dagar/sphinx-mkdocs-migrate/blob/main/CHANGELOG.md"
@@ -4,5 +4,5 @@ Deterministic, evidence-driven analyzer and migration engine from MkDocs to Sphi
4
4
  """
5
5
 
6
6
  # PEP 440 developmental release version
7
- __version__ = "0.0.1.dev0"
7
+ __version__ = "0.0.1.dev1"
8
8
  __all__ = ["__version__"]
@@ -59,6 +59,11 @@ class MkDocsConfigAnalyzer:
59
59
  if not mkdocs_file or not mkdocs_file.exists():
60
60
  return ConfigAnalysis()
61
61
 
62
+ try:
63
+ rel_config_path = str(mkdocs_file.relative_to(self.project_root))
64
+ except ValueError:
65
+ rel_config_path = str(mkdocs_file)
66
+
62
67
  try:
63
68
  content = mkdocs_file.read_text(encoding="utf-8")
64
69
  data = yaml.load(content, Loader=SafeMkDocsLoader) or {}
@@ -67,13 +72,14 @@ class MkDocsConfigAnalyzer:
67
72
  # Fallback simple load
68
73
  data = yaml.safe_load(mkdocs_file.read_text(encoding="utf-8")) or {}
69
74
  except Exception:
70
- return ConfigAnalysis()
75
+ return ConfigAnalysis(config_file_path=rel_config_path)
71
76
 
72
77
  theme_data = data.get("theme", {})
73
78
  theme_logo = None
74
79
  theme_icon = None
75
80
  theme_favicon = None
76
81
  theme_language = None
82
+ theme_custom_dir = None
77
83
  theme_font = None
78
84
  theme_palette: List[ThemePalette] = []
79
85
 
@@ -91,6 +97,7 @@ class MkDocsConfigAnalyzer:
91
97
  )
92
98
  theme_favicon = theme_data.get("favicon")
93
99
  theme_language = theme_data.get("language")
100
+ theme_custom_dir = theme_data.get("custom_dir")
94
101
 
95
102
  # Palette parsing (can be a dict or list of dicts in Material for MkDocs)
96
103
  pal = theme_data.get("palette")
@@ -185,7 +192,19 @@ class MkDocsConfigAnalyzer:
185
192
  extra_js = [extra_js]
186
193
  extra = data.get("extra", {}) if isinstance(data.get("extra"), dict) else {}
187
194
 
195
+ raw_exclude = data.get("exclude_docs", [])
196
+ if isinstance(raw_exclude, str):
197
+ exclude_docs = [s.strip() for s in raw_exclude.splitlines() if s.strip()]
198
+ elif isinstance(raw_exclude, list):
199
+ exclude_docs = [str(s) for s in raw_exclude]
200
+ else:
201
+ exclude_docs = []
202
+
203
+ raw_use_dir = data.get("use_directory_urls")
204
+ use_dir_urls = bool(raw_use_dir) if raw_use_dir is not None else None
205
+
188
206
  return ConfigAnalysis(
207
+ config_file_path=rel_config_path,
189
208
  site_name=site_name,
190
209
  site_description=data.get("site_description"),
191
210
  site_author=data.get("site_author"),
@@ -200,6 +219,7 @@ class MkDocsConfigAnalyzer:
200
219
  theme_icon=theme_icon,
201
220
  theme_favicon=theme_favicon,
202
221
  theme_language=theme_language,
222
+ theme_custom_dir=theme_custom_dir,
203
223
  theme_palette=theme_palette,
204
224
  theme_font=theme_font,
205
225
  theme_features=features,
@@ -211,24 +231,42 @@ class MkDocsConfigAnalyzer:
211
231
  extra_css=extra_css,
212
232
  extra_javascript=extra_js,
213
233
  extra=extra,
234
+ exclude_docs=exclude_docs,
235
+ use_directory_urls=use_dir_urls,
214
236
  raw_config_keys=list(data.keys()) if isinstance(data, dict) else [],
215
237
  )
216
238
 
217
239
 
218
- def detect_obsolete_generator_scripts(
240
+ def detect_obsolete_mkdocs_files(
219
241
  project_root: Path,
220
242
  mkdocs_config: Optional[ConfigAnalysis],
221
243
  additional_scripts: Optional[List[str]] = None,
222
244
  ) -> List[str]:
223
- """Detect obsolete MkDocs generator scripts and hooks (e.g. scripts/gen_ref_nav.py) that should be removed.
245
+ """Detect obsolete MkDocs configuration files, generator scripts, and hooks that should be removed upon migration.
224
246
 
225
- These scripts run at build time under MkDocs (e.g., mkdocs-gen-files) to generate virtual stubs.
226
- Once Sphinx autodoc/autosummary is configured and MkDocs dependencies are removed, these scripts
227
- become obsolete, broken, and trigger repo lint failures.
247
+ 1. MkDocs configuration files (e.g. mkdocs.yml, mkdocs.yaml) which are superseded by Sphinx conf.py.
248
+ 2. Generator scripts and hooks (e.g. scripts/gen_ref_nav.py) that run at build time under MkDocs
249
+ (e.g., mkdocs-gen-files) to generate virtual stubs. Once Sphinx autodoc/autosummary is configured
250
+ and MkDocs dependencies are removed, these scripts become obsolete, broken, and trigger repo lint failures.
228
251
  """
252
+ obsolete: List[str] = []
253
+
254
+ # 1. MkDocs configuration files
255
+ if mkdocs_config and mkdocs_config.config_file_path:
256
+ cfg_path = project_root / mkdocs_config.config_file_path
257
+ if cfg_path.is_file() and mkdocs_config.config_file_path not in obsolete:
258
+ obsolete.append(mkdocs_config.config_file_path)
259
+
260
+ # Check common root configuration candidates if not already detected
261
+ for candidate in ["mkdocs.yml", "mkdocs.yaml", ".mkdocs.yml", ".mkdocs.yaml"]:
262
+ cand_path = project_root / candidate
263
+ if cand_path.is_file() and candidate not in obsolete:
264
+ obsolete.append(candidate)
265
+
229
266
  if not mkdocs_config:
230
- return []
267
+ return sorted(obsolete)
231
268
 
269
+ # 2. Generator scripts and hooks
232
270
  candidate_scripts: set[str] = set()
233
271
  gen_cfg = mkdocs_config.plugins_config.get("gen-files", {})
234
272
  if isinstance(gen_cfg, dict):
@@ -245,7 +283,6 @@ def detect_obsolete_generator_scripts(
245
283
  if isinstance(hook, str):
246
284
  candidate_scripts.add(hook)
247
285
 
248
- obsolete: List[str] = []
249
286
  for s_rel in sorted(candidate_scripts):
250
287
  s_path = project_root / s_rel
251
288
  if s_path.is_file():
@@ -258,6 +295,10 @@ def detect_obsolete_generator_scripts(
258
295
  or "mkdocstrings" in content
259
296
  or (isinstance(gen_cfg, dict) and s_rel in gen_cfg.get("scripts", []))
260
297
  ):
261
- obsolete.append(s_rel)
298
+ if s_rel not in obsolete:
299
+ obsolete.append(s_rel)
300
+
301
+ return sorted(obsolete)
302
+
262
303
 
263
- return obsolete
304
+ detect_obsolete_generator_scripts = detect_obsolete_mkdocs_files
@@ -163,6 +163,7 @@ class ThemeFont(BaseModel):
163
163
 
164
164
 
165
165
  class ConfigAnalysis(BaseModel):
166
+ config_file_path: Optional[str] = None
166
167
  site_name: Optional[str] = None
167
168
  site_description: Optional[str] = None
168
169
  site_author: Optional[str] = None
@@ -185,9 +186,12 @@ class ConfigAnalysis(BaseModel):
185
186
  markdown_extensions: List[str] = Field(default_factory=list)
186
187
  nav_raw: Optional[Any] = None
187
188
  custom_hooks: List[str] = Field(default_factory=list)
189
+ theme_custom_dir: Optional[str] = None
188
190
  extra_css: List[str] = Field(default_factory=list)
189
191
  extra_javascript: List[str] = Field(default_factory=list)
190
192
  extra: Dict[str, Any] = Field(default_factory=dict)
193
+ exclude_docs: List[str] = Field(default_factory=list)
194
+ use_directory_urls: Optional[bool] = None
191
195
  raw_config_keys: List[str] = Field(default_factory=list)
192
196
 
193
197
 
@@ -1,10 +1,18 @@
1
1
  """Subsystem analyzer for MkDocs declarative navigation trees with path normalization and missing file detection."""
2
2
 
3
+ import fnmatch
3
4
  from pathlib import Path, PurePosixPath
4
5
  from typing import List, Any, Optional, Set
6
+ from ..constants import ROOT_DOC_CANDIDATE_STEMS
5
7
  from .models import NavigationItem, NavigationAnalysis
6
8
 
7
9
 
10
+ def is_root_document_path(rel_path: str) -> bool:
11
+ """Returns True if the relative path represents a top-level root document."""
12
+ p = Path(rel_path)
13
+ return len(p.parts) == 1 and p.stem.lower() in ROOT_DOC_CANDIDATE_STEMS
14
+
15
+
8
16
  def _normalize_nav_path(raw_path: str) -> str:
9
17
  """Normalizes relative navigation paths (e.g. './guide/index.md#sec' -> 'guide/index.md')."""
10
18
  clean = raw_path.strip()
@@ -91,10 +99,30 @@ class NavigationAnalyzer:
91
99
  else:
92
100
  missing_refs.append(ref)
93
101
 
94
- # Detect orphan documents (on disk, but not in nav, excluding index.md)
102
+ def is_covered_by_wildcard(disc_path: str) -> bool:
103
+ for w_ref in generated_wildcard_refs:
104
+ pat = w_ref.split("|", 1)[1].strip() if "|" in w_ref else w_ref.strip()
105
+ pat = pat.replace("...", "").strip().lstrip("./").lstrip("/")
106
+ if not pat:
107
+ continue
108
+ if fnmatch.fnmatch(disc_path, pat):
109
+ return True
110
+ if fnmatch.fnmatch(disc_path, f"{pat}.md"):
111
+ return True
112
+ if pat.endswith("/*"):
113
+ base_prefix = pat[:-2]
114
+ if disc_path.startswith(f"{base_prefix}/"):
115
+ return True
116
+ return False
117
+
118
+ # Detect orphan documents (on disk, but not in nav, excluding root landing documents)
95
119
  orphans: List[str] = []
96
120
  for disc in discovered_rel_paths:
97
- if disc not in referenced_paths and disc != "index.md":
121
+ if (
122
+ disc not in referenced_paths
123
+ and not is_root_document_path(disc)
124
+ and not is_covered_by_wildcard(disc)
125
+ ):
98
126
  orphans.append(disc)
99
127
 
100
128
  return NavigationAnalysis(
@@ -1,14 +1,20 @@
1
1
  """Command-line interface for deterministic MkDocs-to-Sphinx migration."""
2
2
 
3
- import sys
3
+ import importlib.util
4
4
  import json
5
5
  from pathlib import Path
6
- from typing import Optional
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import Dict, List, Optional
10
+
7
11
  import click
8
12
  from rich.console import Console
9
- from rich.table import Table
13
+ from rich.prompt import Confirm, Prompt
10
14
  from rich.syntax import Syntax
15
+ from rich.table import Table
11
16
 
17
+ from .analyzer.models import Classification
12
18
  from .analyzer.project import ProjectAnalyzer
13
19
  from .planner.planner import MigrationPlanner
14
20
  from .transformer.engine import TransformationEngine
@@ -18,6 +24,170 @@ from . import __version__
18
24
  console = Console()
19
25
 
20
26
 
27
+ def _normalize_module_name(pkg_name: str) -> str:
28
+ """Normalize a Python package name to its top-level importable module name."""
29
+ mapping = {
30
+ "myst-parser": "myst_parser",
31
+ "sphinx-immaterial": "sphinx_immaterial",
32
+ "sphinx-design": "sphinx_design",
33
+ "sphinx-copybutton": "sphinx_copybutton",
34
+ "sphinx-rtd-theme": "sphinx_rtd_theme",
35
+ "sphinxcontrib-mermaid": "sphinxcontrib.mermaid",
36
+ "mkdocs-material": "material",
37
+ }
38
+ m = re.match(r"^([a-zA-Z0-9_\-\.]+)", pkg_name)
39
+ base = (
40
+ m.group(1).lower().replace("_", "-")
41
+ if m
42
+ else pkg_name.lower().replace("_", "-")
43
+ )
44
+ return mapping.get(base, base.replace("-", "_"))
45
+
46
+
47
+ def _get_missing_sphinx_packages(candidates: List[str]) -> List[str]:
48
+ """Return candidates that are not found in the current Python environment."""
49
+ missing: List[str] = []
50
+ for pkg in candidates:
51
+ mod = _normalize_module_name(pkg)
52
+ try:
53
+ if importlib.util.find_spec(mod) is None:
54
+ missing.append(pkg)
55
+ except (ModuleNotFoundError, ValueError):
56
+ missing.append(pkg)
57
+ return missing
58
+
59
+
60
+ def _get_installed_mkdocs_packages(candidates: List[str]) -> List[str]:
61
+ """Return MkDocs packages that are currently installed in the environment."""
62
+ installed: List[str] = []
63
+ for pkg in candidates:
64
+ mod = _normalize_module_name(pkg)
65
+ try:
66
+ if importlib.util.find_spec(mod) is not None:
67
+ installed.append(pkg)
68
+ except (ModuleNotFoundError, ValueError):
69
+ pass
70
+ return installed
71
+
72
+
73
+ def _is_interactive() -> bool:
74
+ """Return whether the current session is running in an interactive terminal."""
75
+ return sys.stdin.isatty()
76
+
77
+
78
+ def _collect_manual_review_resolutions(
79
+ plan,
80
+ project_root: Path,
81
+ ) -> Dict[str, str]:
82
+ """Interactively prompt user to resolve constructs requiring manual review.
83
+
84
+ Returns:
85
+ Dict mapping action_id -> custom replacement text.
86
+ """
87
+ manual_actions = [
88
+ act
89
+ for act in plan.document_actions
90
+ if act.classification == Classification.MANUAL
91
+ ]
92
+ if not manual_actions:
93
+ return {}
94
+
95
+ manual_actions.sort(key=lambda a: (a.source_file, a.start_line))
96
+ total = len(manual_actions)
97
+
98
+ console.print(
99
+ f"\n[bold yellow]Constructs Requiring Manual Review ({total}):[/bold yellow]"
100
+ )
101
+ console.print(
102
+ "[dim]You can review each construct to keep it as-is, comment it out, or enter a custom MyST replacement.[/dim]\n"
103
+ )
104
+
105
+ overrides: Dict[str, str] = {}
106
+ for idx, act in enumerate(manual_actions, 1):
107
+ src_path = project_root / act.source_file
108
+ snippet = ""
109
+ if src_path.exists():
110
+ try:
111
+ lines = src_path.read_text(encoding="utf-8").splitlines(keepends=True)
112
+ start_idx = max(0, act.start_line - 1)
113
+ end_idx = min(len(lines), act.end_line)
114
+ snippet = "".join(lines[start_idx:end_idx])
115
+ except Exception:
116
+ snippet = act.description
117
+ else:
118
+ snippet = act.description
119
+
120
+ console.print(
121
+ f"[bold cyan]Item {idx}/{total}:[/bold cyan] [bold]{act.source_file}:{act.start_line}-{act.end_line}[/bold]"
122
+ )
123
+ console.print(
124
+ f" • Construct: [magenta]{act.source_kind.value}[/magenta] ([dim]{act.rule_id}[/dim])"
125
+ )
126
+ if act.manual_instruction:
127
+ console.print(f" • Instruction: [yellow]{act.manual_instruction}[/yellow]")
128
+ console.print()
129
+
130
+ if snippet.strip():
131
+ console.print(
132
+ Syntax(
133
+ snippet.strip(),
134
+ "markdown",
135
+ line_numbers=True,
136
+ start_line=act.start_line,
137
+ )
138
+ )
139
+ console.print()
140
+
141
+ console.print(" [1] Keep original content (safe, default)")
142
+ console.print(
143
+ f" [2] Comment out block (<!-- MANUAL_REVIEW: {act.source_kind.value} -->)"
144
+ )
145
+ console.print(" [3] Enter custom replacement text")
146
+ console.print(" [s] Skip remaining and keep original for all")
147
+ console.print()
148
+
149
+ choice = Prompt.ask(
150
+ "Choose action",
151
+ choices=["1", "2", "3", "s"],
152
+ default="1",
153
+ )
154
+
155
+ if choice == "1":
156
+ console.print("[dim]Kept original content.[/dim]\n")
157
+ elif choice == "2":
158
+ comment_text = (
159
+ f"<!-- MANUAL_REVIEW: {act.source_kind.value}\n"
160
+ f"{snippet.rstrip()}\n"
161
+ f"-->\n"
162
+ )
163
+ overrides[act.action_id] = comment_text
164
+ console.print("[green]Marked block as commented out.[/green]\n")
165
+ elif choice == "3":
166
+ custom_text = Prompt.ask(
167
+ "Enter custom replacement text (use \\n for newlines)",
168
+ default="",
169
+ )
170
+ if not custom_text.strip():
171
+ if Confirm.ask("Empty replacement: delete block?", default=False):
172
+ overrides[act.action_id] = ""
173
+ console.print("[green]Block marked for deletion.[/green]\n")
174
+ else:
175
+ console.print("[dim]Kept original content.[/dim]\n")
176
+ else:
177
+ custom_text = custom_text.replace(r"\n", "\n")
178
+ if not custom_text.endswith("\n"):
179
+ custom_text += "\n"
180
+ overrides[act.action_id] = custom_text
181
+ console.print("[green]Custom replacement saved.[/green]\n")
182
+ elif choice == "s":
183
+ console.print(
184
+ "[dim]Skipping remaining items; keeping original content.[/dim]\n"
185
+ )
186
+ break
187
+
188
+ return overrides
189
+
190
+
21
191
  @click.group()
22
192
  @click.version_option(version=__version__, prog_name="sphinx-migrate")
23
193
  def main():
@@ -259,6 +429,33 @@ def plan(project_path: Path, output_json_path: Optional[Path], json_stdout: bool
259
429
  default=False,
260
430
  help="Write transformed files and conf.py to disk.",
261
431
  )
432
+ @click.option(
433
+ "--yes",
434
+ "-y",
435
+ "auto_approve",
436
+ is_flag=True,
437
+ default=False,
438
+ help="Automatically confirm prompts and apply migration without interactive confirmation.",
439
+ )
440
+ @click.option(
441
+ "--interactive/--no-interactive",
442
+ "-i",
443
+ "interactive",
444
+ default=None,
445
+ help="Prompt interactively to resolve constructs requiring manual review.",
446
+ )
447
+ @click.option(
448
+ "--install-deps/--no-install-deps",
449
+ "install_deps",
450
+ default=None,
451
+ help="Automatically install (or skip installing) missing Sphinx dependencies in current environment.",
452
+ )
453
+ @click.option(
454
+ "--uninstall-mkdocs/--no-uninstall-mkdocs",
455
+ "uninstall_mkdocs",
456
+ default=None,
457
+ help="Automatically uninstall (or skip uninstalling) obsolete MkDocs dependencies from current environment.",
458
+ )
262
459
  @click.option(
263
460
  "--force-conf",
264
461
  "overwrite_conf",
@@ -295,6 +492,10 @@ def plan(project_path: Path, output_json_path: Optional[Path], json_stdout: bool
295
492
  def migrate(
296
493
  project_path: Path,
297
494
  write_to_disk: bool,
495
+ auto_approve: bool,
496
+ interactive: Optional[bool],
497
+ install_deps: Optional[bool],
498
+ uninstall_mkdocs: Optional[bool],
298
499
  overwrite_conf: bool,
299
500
  show_diff: bool,
300
501
  run_validation: bool,
@@ -314,8 +515,64 @@ def migrate(
314
515
  planner = MigrationPlanner(project_path)
315
516
  plan = planner.create_plan()
316
517
 
317
- # 2. Execute Transformation
318
- engine = TransformationEngine(plan)
518
+ # Pre-flight check & interactive confirmation
519
+ to_add: List[str] = (
520
+ list(plan.dependency_analysis.suggested_packages_to_add)
521
+ if plan.dependency_analysis
522
+ else []
523
+ )
524
+ if plan.documentation_plan:
525
+ for req_pkg in plan.documentation_plan.required_packages or []:
526
+ to_add.append(req_pkg)
527
+ if "sphinx_copybutton" in (plan.documentation_plan.required_extensions or []):
528
+ to_add.append("sphinx-copybutton")
529
+ to_add = sorted(list(dict.fromkeys(to_add)))
530
+
531
+ to_remove: List[str] = list(plan.packages_to_remove or [])
532
+ if plan.dependency_analysis:
533
+ to_remove.extend(plan.dependency_analysis.detected_packages_to_remove)
534
+ to_remove = sorted(list(dict.fromkeys(to_remove)))
535
+
536
+ if write_to_disk:
537
+ console.print("[bold]Migration Pre-flight Check:[/bold]")
538
+ console.print(f" • Planned transformations: {len(plan.document_actions)}")
539
+ if to_add:
540
+ console.print(
541
+ f" • Sphinx dependencies to add: [green]{', '.join(to_add)}[/green]"
542
+ )
543
+ if to_remove:
544
+ console.print(
545
+ f" • MkDocs dependencies to remove: [yellow]{', '.join(to_remove)}[/yellow]"
546
+ )
547
+ if plan.obsolete_files:
548
+ console.print(
549
+ f" • Obsolete MkDocs files to remove: [yellow]{', '.join(plan.obsolete_files)}[/yellow]"
550
+ )
551
+ console.print()
552
+
553
+ if not auto_approve and _is_interactive():
554
+ if not Confirm.ask("Apply migration changes to disk?", default=True):
555
+ console.print("[yellow]Migration cancelled by user.[/yellow]")
556
+ return
557
+
558
+ # 2. Collect manual review resolutions if interactive
559
+ manual_overrides: Dict[str, str] = {}
560
+ has_manual = any(
561
+ act.classification == Classification.MANUAL for act in plan.document_actions
562
+ )
563
+ should_prompt_manual = (
564
+ has_manual
565
+ and not auto_approve
566
+ and (
567
+ interactive is True
568
+ or (interactive is None and write_to_disk and _is_interactive())
569
+ )
570
+ )
571
+ if should_prompt_manual:
572
+ manual_overrides = _collect_manual_review_resolutions(plan, project_path)
573
+
574
+ # 3. Execute Transformation
575
+ engine = TransformationEngine(plan, manual_overrides=manual_overrides)
319
576
  report = engine.execute(write_to_disk=write_to_disk, overwrite_conf=overwrite_conf)
320
577
 
321
578
  res_table = Table(
@@ -423,7 +680,86 @@ def migrate(
423
680
  for cf in report.cleaned_files:
424
681
  console.print(f" - [yellow]{cf}[/yellow]")
425
682
  console.print()
426
- if not write_to_disk:
683
+
684
+ if write_to_disk:
685
+ # Check missing Sphinx packages in current environment
686
+ missing_sphinx = _get_missing_sphinx_packages(to_add)
687
+ if missing_sphinx:
688
+ console.print(
689
+ "[bold yellow]Notice: Missing Sphinx dependencies in current environment:[/bold yellow]"
690
+ )
691
+ for p in missing_sphinx:
692
+ console.print(f" • [yellow]{p}[/yellow]")
693
+ console.print()
694
+
695
+ do_install = False
696
+ if install_deps is True:
697
+ do_install = True
698
+ elif install_deps is None and not auto_approve and _is_interactive():
699
+ do_install = Confirm.ask(
700
+ f"Install {len(missing_sphinx)} missing Sphinx dependencies into current environment?",
701
+ default=True,
702
+ )
703
+
704
+ if do_install:
705
+ console.print(
706
+ f"[bold cyan]Installing {', '.join(missing_sphinx)}...[/bold cyan]"
707
+ )
708
+ res = subprocess.run(
709
+ [sys.executable, "-m", "pip", "install", *missing_sphinx]
710
+ )
711
+ if res.returncode == 0:
712
+ console.print(
713
+ "[bold green]✔ Dependencies installed successfully.[/bold green]\n"
714
+ )
715
+ else:
716
+ console.print(
717
+ "[bold red]✖ Failed to install dependencies via pip.[/bold red]\n"
718
+ )
719
+ else:
720
+ console.print(
721
+ f"[dim]To install manually: [bold]pip install {' '.join(missing_sphinx)}[/bold][/dim]\n"
722
+ )
723
+
724
+ # Check installed obsolete MkDocs packages in current environment
725
+ installed_mkdocs = _get_installed_mkdocs_packages(to_remove)
726
+ if installed_mkdocs:
727
+ console.print(
728
+ "[bold yellow]Notice: Found obsolete MkDocs dependencies in current environment:[/bold yellow]"
729
+ )
730
+ for p in installed_mkdocs:
731
+ console.print(f" • [yellow]{p}[/yellow]")
732
+ console.print()
733
+
734
+ do_uninstall = False
735
+ if uninstall_mkdocs is True:
736
+ do_uninstall = True
737
+ elif uninstall_mkdocs is None and not auto_approve and _is_interactive():
738
+ do_uninstall = Confirm.ask(
739
+ f"Uninstall {len(installed_mkdocs)} obsolete MkDocs dependencies from current environment?",
740
+ default=False,
741
+ )
742
+
743
+ if do_uninstall:
744
+ console.print(
745
+ f"[bold cyan]Uninstalling {', '.join(installed_mkdocs)}...[/bold cyan]"
746
+ )
747
+ res = subprocess.run(
748
+ [sys.executable, "-m", "pip", "uninstall", "-y", *installed_mkdocs]
749
+ )
750
+ if res.returncode == 0:
751
+ console.print(
752
+ "[bold green]✔ Obsolete MkDocs dependencies uninstalled.[/bold green]\n"
753
+ )
754
+ else:
755
+ console.print(
756
+ "[bold red]✖ Failed to uninstall dependencies via pip.[/bold red]\n"
757
+ )
758
+ else:
759
+ console.print(
760
+ f"[dim]To uninstall manually: [bold]pip uninstall -y {' '.join(installed_mkdocs)}[/bold][/dim]\n"
761
+ )
762
+ else:
427
763
  console.print(
428
764
  "[dim]Dry-run mode: files were not modified. Pass --apply to write changes to disk.[/dim]"
429
765
  )
@@ -0,0 +1,5 @@
1
+ """Universal constants, defaults, and configuration keys for sphinx-mkdocs-migrate."""
2
+
3
+ DEFAULT_ROOT_DOC = "index"
4
+ ROOT_DOC_CANDIDATE_STEMS = ("index", "readme")
5
+ DEFAULT_ROOT_DOC_FILENAME = f"{DEFAULT_ROOT_DOC}.md"