fix: make MkDocs generation filesystem-complete and package-aware
- Add filesystem-based module discovery via `discover_module_paths` - Decouple documentation coverage from Python import behavior - Ensure GriffeLoader receives a full module list instead of a single root - Make MkDocs renderer level-agnostic using global package detection - Emit `index.md` only for true packages, suppress `<package>.md` - Mirror full dotted module hierarchy into nested docs directories - Update CLI, exports, and type stubs to expose discovery helper - Align tests with filesystem-driven module coverage This fixes missing docs for submodules and removes invalid package `.md` files.
This commit is contained in:
@@ -6,13 +6,14 @@ All the rendering, exporting, and serving APIs are intentionally private
|
||||
until their contracts are finalized.
|
||||
"""
|
||||
|
||||
from .loader import GriffeLoader
|
||||
from .loader import GriffeLoader, discover_module_paths
|
||||
from .renderers import MkDocsRenderer
|
||||
from .cli import main
|
||||
from . import model
|
||||
|
||||
__all__ = [
|
||||
"GriffeLoader",
|
||||
"discover_module_paths",
|
||||
"MkDocsRenderer",
|
||||
"model",
|
||||
"main",
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Sequence, Optional
|
||||
|
||||
import click
|
||||
|
||||
from docforge.loader import GriffeLoader
|
||||
from docforge.loader import GriffeLoader, discover_module_paths
|
||||
from docforge.renderers.mkdocs import MkDocsRenderer
|
||||
from docforge.cli.mkdocs import mkdocs_cmd
|
||||
|
||||
@@ -83,7 +83,14 @@ def generate(
|
||||
) -> None:
|
||||
"""Generate documentation source files using MkDocs renderer."""
|
||||
loader = GriffeLoader()
|
||||
project = loader.load_project(list(modules), project_name)
|
||||
discovered_paths = discover_module_paths(
|
||||
"docforge",
|
||||
Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge")
|
||||
)
|
||||
project = loader.load_project(
|
||||
discovered_paths,
|
||||
project_name
|
||||
)
|
||||
|
||||
renderer = MkDocsRenderer()
|
||||
renderer.generate_sources(project, docs_dir)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||
|
||||
__all__ = [
|
||||
"GriffeLoader"
|
||||
"GriffeLoader",
|
||||
"discover_module_paths",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||
|
||||
__all__ = [
|
||||
"GriffeLoader"
|
||||
"GriffeLoader",
|
||||
"discover_module_paths",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from griffe import (
|
||||
@@ -16,6 +17,41 @@ from docforge.model import Module, Project, DocObject
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_module_paths(
|
||||
module_name: str,
|
||||
project_root: Path | None = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Discover all Python modules under a package via filesystem traversal.
|
||||
|
||||
Rules:
|
||||
- Directory with __init__.py => package
|
||||
- .py file => module
|
||||
- Paths converted to dotted module paths
|
||||
"""
|
||||
|
||||
if project_root is None:
|
||||
project_root = Path.cwd()
|
||||
|
||||
pkg_dir = project_root / module_name
|
||||
if not pkg_dir.exists():
|
||||
raise FileNotFoundError(f"Package not found: {pkg_dir}")
|
||||
|
||||
module_paths: List[str] = []
|
||||
|
||||
for path in pkg_dir.rglob("*.py"):
|
||||
if path.name == "__init__.py":
|
||||
module_path = path.parent
|
||||
else:
|
||||
module_path = path
|
||||
|
||||
rel = module_path.relative_to(project_root)
|
||||
dotted = ".".join(rel.with_suffix("").parts)
|
||||
module_paths.append(dotted)
|
||||
|
||||
return sorted(set(module_paths))
|
||||
|
||||
|
||||
class GriffeLoader:
|
||||
"""Loads Python modules using Griffe introspection."""
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from docforge.model import Module, Project
|
||||
|
||||
|
||||
def discover_module_paths(
|
||||
module_name: str,
|
||||
project_root: Path | None = None,
|
||||
) -> List[str]:
|
||||
...
|
||||
|
||||
|
||||
class GriffeLoader:
|
||||
"""Griffe-based introspection loader.
|
||||
|
||||
|
||||
@@ -11,43 +11,43 @@ class MkDocsRenderer:
|
||||
name = "mkdocs"
|
||||
|
||||
def generate_sources(self, project: Project, out_dir: Path) -> None:
|
||||
"""
|
||||
Generate Markdown files with mkdocstrings directives.
|
||||
modules = list(project.get_all_modules())
|
||||
paths = {m.path for m in modules}
|
||||
|
||||
Structure rules:
|
||||
- Each top-level package gets a directory
|
||||
- Modules become .md files
|
||||
- Packages (__init__) become index.md
|
||||
"""
|
||||
for module in project.get_all_modules():
|
||||
self._write_module(project, module, out_dir)
|
||||
# Package detection (level-agnostic)
|
||||
packages = {
|
||||
p for p in paths
|
||||
if any(other.startswith(p + ".") for other in paths)
|
||||
}
|
||||
|
||||
for module in modules:
|
||||
self._write_module(module, packages, out_dir)
|
||||
|
||||
# -------------------------
|
||||
# Internal helpers
|
||||
# -------------------------
|
||||
|
||||
def _write_module(self, project: Project, module, out_dir: Path) -> None:
|
||||
def _write_module(self, module, packages: set[str], out_dir: Path) -> None:
|
||||
parts = module.path.split(".")
|
||||
|
||||
# Root package directory
|
||||
pkg_dir = out_dir / parts[0]
|
||||
pkg_dir.mkdir(parents=True, exist_ok=True)
|
||||
if module.path in packages:
|
||||
# package → index.md
|
||||
dir_path = out_dir.joinpath(*parts)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Package (__init__.py) → index.md
|
||||
if module.path == parts[0]:
|
||||
md_path = pkg_dir / "index.md"
|
||||
title = parts[0].replace("_", " ").title()
|
||||
md_path = dir_path / "index.md"
|
||||
title = parts[-1].replace("_", " ").title()
|
||||
else:
|
||||
# Submodule → <name>.md
|
||||
md_path = pkg_dir / f"{parts[-1]}.md"
|
||||
# leaf module → <name>.md
|
||||
dir_path = out_dir.joinpath(*parts[:-1])
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
md_path = dir_path / f"{parts[-1]}.md"
|
||||
title = parts[-1].replace("_", " ").title()
|
||||
|
||||
content = self._render_markdown(title, module.path)
|
||||
|
||||
# Idempotent write
|
||||
if md_path.exists():
|
||||
if md_path.read_text(encoding="utf-8") == content:
|
||||
return
|
||||
if md_path.exists() and md_path.read_text(encoding="utf-8") == content:
|
||||
return
|
||||
|
||||
md_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from docforge.model import Project
|
||||
from docforge.renderers.base import DocRenderer
|
||||
from docforge.model import Project, Module
|
||||
|
||||
|
||||
class MkDocsRenderer:
|
||||
"""MkDocs source generator using mkdocstrings."""
|
||||
|
||||
name: str
|
||||
|
||||
def generate_sources(
|
||||
def generate_sources(self, project: Project, out_dir: Path) -> None: ...
|
||||
def _write_module(
|
||||
self,
|
||||
project: Project,
|
||||
module: Module,
|
||||
packages: Set[str],
|
||||
out_dir: Path,
|
||||
) -> None:
|
||||
"""Generate Markdown files with mkdocstrings directives."""
|
||||
) -> None: ...
|
||||
def _render_markdown(self, title: str, module_path: str) -> str: ...
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
from docforge.loader import GriffeLoader
|
||||
from docforge.loader import GriffeLoader, discover_module_paths
|
||||
from docforge.renderers.mkdocs import MkDocsRenderer
|
||||
|
||||
|
||||
def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
|
||||
loader = GriffeLoader()
|
||||
project = loader.load_project(["docforge"])
|
||||
discovered_paths = discover_module_paths(
|
||||
"docforge",
|
||||
Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge")
|
||||
)
|
||||
project = loader.load_project(
|
||||
discovered_paths
|
||||
)
|
||||
|
||||
renderer = MkDocsRenderer()
|
||||
renderer.generate_sources(project, tmp_path)
|
||||
@@ -32,5 +38,24 @@ def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
|
||||
else:
|
||||
expected.add("/".join(parts) + ".md")
|
||||
|
||||
# expected = {
|
||||
# 'docforge/cli/main.md',
|
||||
# 'docforge/renderers/index.md',
|
||||
# 'docforge/loader/index.md',
|
||||
# 'docforge/model/index.md',
|
||||
# 'docforge/nav/index.md',
|
||||
# 'docforge/renderers/mkdocs.md',
|
||||
# 'docforge/index.md',
|
||||
# 'docforge/loader/griffe_loader.md',
|
||||
# 'docforge/model/object.md',
|
||||
# 'docforge/cli/index.md',
|
||||
# 'docforge/nav/resolver.md',
|
||||
# 'docforge/renderers/base.md',
|
||||
# 'docforge/nav/mkdocs.md',
|
||||
# 'docforge/nav/spec.md',
|
||||
# 'docforge/model/module.md',
|
||||
# 'docforge/cli/mkdocs.md',
|
||||
# 'docforge/model/project.md'
|
||||
# }
|
||||
missing = expected - emitted
|
||||
assert not missing, f"Missing markdown files for modules: {missing}"
|
||||
|
||||
Reference in New Issue
Block a user