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.
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/PKG-INFO +6 -5
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/README.md +4 -3
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/pyproject.toml +1 -1
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/__init__.py +1 -1
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/mkdocs.py +51 -10
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/models.py +4 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/navigation.py +30 -2
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/cli.py +342 -6
- sphinx_mkdocs_migrate-0.0.1.dev1/src/sphinx_mkdocs_migrate/constants.py +5 -0
- sphinx_mkdocs_migrate-0.0.1.dev1/src/sphinx_mkdocs_migrate/parsing/markdown.py +47 -0
- {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
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/conf_builder.py +13 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/models.py +1 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/planner.py +191 -8
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/toctree.py +9 -6
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/engine.py +263 -26
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/myst_transformer.py +23 -3
- sphinx_mkdocs_migrate-0.0.1.dev0/src/sphinx_mkdocs_migrate/parsing/markdown.py +0 -22
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/.gitignore +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/LICENSE +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/MIGRATION_GUIDE.md +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/__init__.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/ci.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/dependencies.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/markdown.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/analyzer/project.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/doc_ir.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/flow_extractor.py +0 -0
- {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
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/markdown_ir.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/parsing/requirements.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/accountability.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/ci.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/policy.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/planner/theme_constants.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/py.typed +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/catalog.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/engine.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/rules/models.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/transformer/models.py +0 -0
- {sphinx_mkdocs_migrate-0.0.1.dev0 → sphinx_mkdocs_migrate-0.0.1.dev1}/src/sphinx_mkdocs_migrate/validator/models.py +0 -0
- {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.
|
|
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://
|
|
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
|
[](https://pypi.org/project/sphinx-mkdocs-migrate/)
|
|
77
|
+
[](https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/?badge=latest)
|
|
77
78
|
[](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
|
[](https://pypi.org/project/sphinx-mkdocs-migrate/)
|
|
4
|
+
[](https://sphinx-mkdocs-migrate.readthedocs.io/en/latest/?badge=latest)
|
|
4
5
|
[](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://
|
|
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"
|
|
@@ -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
|
|
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
|
|
245
|
+
"""Detect obsolete MkDocs configuration files, generator scripts, and hooks that should be removed upon migration.
|
|
224
246
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
298
|
+
if s_rel not in obsolete:
|
|
299
|
+
obsolete.append(s_rel)
|
|
300
|
+
|
|
301
|
+
return sorted(obsolete)
|
|
302
|
+
|
|
262
303
|
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3
|
+
import importlib.util
|
|
4
4
|
import json
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
|
|
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.
|
|
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
|
-
#
|
|
318
|
-
|
|
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
|
-
|
|
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
|
)
|