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:
2026-01-20 23:25:56 +05:30
parent 2e5d330fca
commit dca19caaf3
9 changed files with 120 additions and 41 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -1,5 +1,6 @@
from .griffe_loader import GriffeLoader
from .griffe_loader import GriffeLoader, discover_module_paths
__all__ = [
"GriffeLoader"
"GriffeLoader",
"discover_module_paths",
]

View File

@@ -1,5 +1,6 @@
from .griffe_loader import GriffeLoader
from .griffe_loader import GriffeLoader, discover_module_paths
__all__ = [
"GriffeLoader"
"GriffeLoader",
"discover_module_paths",
]

View File

@@ -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."""

View File

@@ -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.

View File

@@ -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")

View File

@@ -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: ...