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. until their contracts are finalized.
""" """
from .loader import GriffeLoader from .loader import GriffeLoader, discover_module_paths
from .renderers import MkDocsRenderer from .renderers import MkDocsRenderer
from .cli import main from .cli import main
from . import model from . import model
__all__ = [ __all__ = [
"GriffeLoader", "GriffeLoader",
"discover_module_paths",
"MkDocsRenderer", "MkDocsRenderer",
"model", "model",
"main", "main",

View File

@@ -5,7 +5,7 @@ from typing import Sequence, Optional
import click import click
from docforge.loader import GriffeLoader from docforge.loader import GriffeLoader, discover_module_paths
from docforge.renderers.mkdocs import MkDocsRenderer from docforge.renderers.mkdocs import MkDocsRenderer
from docforge.cli.mkdocs import mkdocs_cmd from docforge.cli.mkdocs import mkdocs_cmd
@@ -83,7 +83,14 @@ def generate(
) -> None: ) -> None:
"""Generate documentation source files using MkDocs renderer.""" """Generate documentation source files using MkDocs renderer."""
loader = GriffeLoader() 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 = MkDocsRenderer()
renderer.generate_sources(project, docs_dir) 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__ = [ __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__ = [ __all__ = [
"GriffeLoader" "GriffeLoader",
"discover_module_paths",
] ]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path
from typing import List, Optional from typing import List, Optional
from griffe import ( from griffe import (
@@ -16,6 +17,41 @@ from docforge.model import Module, Project, DocObject
logger = logging.getLogger(__name__) 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: class GriffeLoader:
"""Loads Python modules using Griffe introspection.""" """Loads Python modules using Griffe introspection."""

View File

@@ -1,8 +1,16 @@
from typing import List, Optional from typing import List, Optional
from pathlib import Path
from docforge.model import Module, Project from docforge.model import Module, Project
def discover_module_paths(
module_name: str,
project_root: Path | None = None,
) -> List[str]:
...
class GriffeLoader: class GriffeLoader:
"""Griffe-based introspection loader. """Griffe-based introspection loader.

View File

@@ -11,43 +11,43 @@ class MkDocsRenderer:
name = "mkdocs" name = "mkdocs"
def generate_sources(self, project: Project, out_dir: Path) -> None: def generate_sources(self, project: Project, out_dir: Path) -> None:
""" modules = list(project.get_all_modules())
Generate Markdown files with mkdocstrings directives. paths = {m.path for m in modules}
Structure rules: # Package detection (level-agnostic)
- Each top-level package gets a directory packages = {
- Modules become .md files p for p in paths
- Packages (__init__) become index.md if any(other.startswith(p + ".") for other in paths)
""" }
for module in project.get_all_modules():
self._write_module(project, module, out_dir) for module in modules:
self._write_module(module, packages, out_dir)
# ------------------------- # -------------------------
# Internal helpers # Internal helpers
# ------------------------- # -------------------------
def _write_module(self, module, packages: set[str], out_dir: Path) -> None:
def _write_module(self, project: Project, module, out_dir: Path) -> None:
parts = module.path.split(".") parts = module.path.split(".")
# Root package directory if module.path in packages:
pkg_dir = out_dir / parts[0] # package → index.md
pkg_dir.mkdir(parents=True, exist_ok=True) dir_path = out_dir.joinpath(*parts)
dir_path.mkdir(parents=True, exist_ok=True)
# Package (__init__.py) → index.md md_path = dir_path / "index.md"
if module.path == parts[0]: title = parts[-1].replace("_", " ").title()
md_path = pkg_dir / "index.md"
title = parts[0].replace("_", " ").title()
else: else:
# Submodule → <name>.md # leaf module → <name>.md
md_path = pkg_dir / f"{parts[-1]}.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() title = parts[-1].replace("_", " ").title()
content = self._render_markdown(title, module.path) content = self._render_markdown(title, module.path)
# Idempotent write if md_path.exists() and md_path.read_text(encoding="utf-8") == content:
if md_path.exists(): return
if md_path.read_text(encoding="utf-8") == content:
return
md_path.write_text(content, encoding="utf-8") md_path.write_text(content, encoding="utf-8")

View File

@@ -1,17 +1,17 @@
from pathlib import Path from pathlib import Path
from typing import Set
from docforge.model import Project from docforge.model import Project, Module
from docforge.renderers.base import DocRenderer
class MkDocsRenderer: class MkDocsRenderer:
"""MkDocs source generator using mkdocstrings."""
name: str name: str
def generate_sources( def generate_sources(self, project: Project, out_dir: Path) -> None: ...
def _write_module(
self, self,
project: Project, module: Module,
packages: Set[str],
out_dir: Path, out_dir: Path,
) -> None: ) -> None: ...
"""Generate Markdown files with mkdocstrings directives.""" def _render_markdown(self, title: str, module_path: str) -> str: ...

View File

@@ -1,12 +1,18 @@
from pathlib import Path from pathlib import Path
from docforge.loader import GriffeLoader from docforge.loader import GriffeLoader, discover_module_paths
from docforge.renderers.mkdocs import MkDocsRenderer from docforge.renderers.mkdocs import MkDocsRenderer
def test_mkdocs_emits_all_modules(tmp_path: Path) -> None: def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
loader = GriffeLoader() 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 = MkDocsRenderer()
renderer.generate_sources(project, tmp_path) renderer.generate_sources(project, tmp_path)
@@ -32,5 +38,24 @@ def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
else: else:
expected.add("/".join(parts) + ".md") 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 missing = expected - emitted
assert not missing, f"Missing markdown files for modules: {missing}" assert not missing, f"Missing markdown files for modules: {missing}"