Compare commits

..

8 Commits

Author SHA1 Message Date
dca19caaf3 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.
2026-01-20 23:25:56 +05:30
2e5d330fca missing internal modules test case 2026-01-20 21:52:44 +05:30
5073f9d73f updated .gitignore 2026-01-20 21:43:24 +05:30
94b90f2ccf added .drone.yml 2026-01-20 21:41:37 +05:30
a8ba02c57b mkdocs cli 2026-01-20 21:40:18 +05:30
726e7ca6d2 nav submodule 2026-01-20 21:22:28 +05:30
869b1730c4 version fix 2026-01-20 20:54:49 +05:30
09aca78ba1 standardize toml as per existing libs 2026-01-20 20:54:10 +05:30
28 changed files with 1246 additions and 75 deletions

129
.drone.yml Normal file
View File

@@ -0,0 +1,129 @@
---
kind: pipeline
type: docker
name: build-and-publish-pypi
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: check-version
image: curlimages/curl:latest
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | cut -d'"' -f2)
- echo "🔍 Checking if $PACKAGE_NAME==$VERSION exists on $PIP_REPO_URL ..."
- |
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" "$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -q "$VERSION"; then
echo "✅ $PACKAGE_NAME==$VERSION already exists — skipping build."
exit 78
else
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
fi
- name: build-package
image: python:3.13-slim
commands:
- pip install --upgrade pip build
- echo "📦 Building Python package..."
- python -m build
- ls -l dist
- name: upload-to-private-pypi
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade twine
- echo "🚀 Uploading to private PyPI at $PIP_REPO_URL ..."
- |
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
trigger:
event:
- tag
---
kind: pipeline
type: docker
name: backfill-pypi-from-tags
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
steps:
- name: fetch-tags
image: alpine/git
commands:
- git fetch --tags --force
- name: build-and-upload-missing
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- apt-get update
- apt-get install -y git curl ca-certificates
- pip install --upgrade pip build twine
- |
set -e
PACKAGE_NAME=$(grep -E '^name\s*=' pyproject.toml | cut -d'"' -f2)
echo "📦 Package: $PACKAGE_NAME"
for TAG in $(git tag --sort=version:refname); do
VERSION="$TAG"
echo "🔁 Version: $VERSION"
if curl -fsSL -u "$PIP_USERNAME:$PIP_PASSWORD" \
"$PIP_REPO_URL/simple/$PACKAGE_NAME/" | grep -q "$VERSION"; then
echo "⏭️ Exists, skipping"
continue
fi
git checkout --force "$TAG"
echo "🏗️ Building $VERSION"
rm -rf dist
python -m build
echo "⬆️ Uploading $VERSION"
twine upload \
--repository-url "$PIP_REPO_URL" \
-u "$PIP_USERNAME" \
-p "$PIP_PASSWORD" \
dist/*
done
trigger:
event:
- custom

91
.gitignore vendored
View File

@@ -1 +1,90 @@
.idea
# =========================
# Python bytecode & caches
# =========================
__pycache__/
*.py[cod]
*$py.class
# =========================
# C extensions
# =========================
*.so
# =========================
# Distribution / packaging
# =========================
.Python
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
wheels/
MANIFEST
# =========================
# Virtual environments
# =========================
.venv/
venv/
ENV/
env/
# =========================
# Test / coverage outputs
# =========================
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.coverage.xml
test-results/
# =========================
# Lint / type checking
# =========================
.mypy_cache/
.dmypy.json
.pyre/
# =========================
# Docs build artifacts
# =========================
site/
docs/_site/
docs/build/
# =========================
# MkDocs / Sphinx output
# =========================
mkdocs.yml
site/
.build/
_doctrees/
# =========================
# IDEs & editors
# =========================
.vscode/
.idea/
*.swp
*.swo
*.bak
*.tmp
# =========================
# OS-specific
# =========================
.DS_Store
Thumbs.db
# =========================
# Logs
# =========================
*.log
# =========================
# Local config overrides
# =========================
.env
.env.*

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,8 +5,9 @@ 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
@click.group()
@@ -15,6 +16,8 @@ def cli() -> None:
pass
cli.add_command(mkdocs_cmd)
# ---------------------------------------------------------------------
# tree
# ---------------------------------------------------------------------
@@ -80,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)

84
docforge/cli/mkdocs.py Normal file
View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from importlib import resources
import click
import yaml
from docforge.nav import load_nav_spec
from docforge.nav import resolve_nav
from docforge.nav import MkDocsNavEmitter
def _load_template(template: Path | None) -> dict:
if template is not None:
if not template.exists():
raise click.FileError(str(template), hint="Template not found")
return yaml.safe_load(template.read_text(encoding="utf-8"))
# Load built-in default
text = (
resources.files("docforge.templates")
.joinpath("mkdocs.sample.yml")
.read_text(encoding="utf-8")
)
return yaml.safe_load(text)
@click.command("mkdocs")
@click.option(
"--docs-dir",
type=click.Path(path_type=Path),
default=Path("docs"),
)
@click.option(
"--nav",
"nav_file",
type=click.Path(path_type=Path),
default=Path("docforge.nav.yml"),
)
@click.option(
"--template",
type=click.Path(path_type=Path),
default=None,
help="Override the built-in mkdocs template",
)
@click.option(
"--out",
type=click.Path(path_type=Path),
default=Path("mkdocs.yml"),
)
def mkdocs_cmd(
docs_dir: Path,
nav_file: Path,
template: Path | None,
out: Path,
) -> None:
"""Generate mkdocs.yml from nav spec and template."""
if not nav_file.exists():
raise click.FileError(str(nav_file), hint="Nav spec not found")
# Load nav spec
spec = load_nav_spec(nav_file)
# Resolve nav
resolved = resolve_nav(spec, docs_dir)
# Emit mkdocs nav
nav_block = MkDocsNavEmitter().emit(resolved)
# Load template (user or built-in)
data = _load_template(template)
# Inject nav
data["nav"] = nav_block
# Write output
out.write_text(
yaml.safe_dump(data, sort_keys=False),
encoding="utf-8",
)
click.echo(f"mkdocs.yml written to {out}")

41
docforge/cli/mkdocs.pyi Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
import click
def _load_template(template: Optional[Path]) -> Dict[str, Any]:
...
@click.command("mkdocs")
@click.option(
"--docs-dir",
type=click.Path(path_type=Path),
default=Path("docs"),
)
@click.option(
"--nav",
"nav_file",
type=click.Path(path_type=Path),
default=Path("docforge.nav.yml"),
)
@click.option(
"--template",
type=click.Path(path_type=Path),
default=None,
)
@click.option(
"--out",
type=click.Path(path_type=Path),
default=Path("mkdocs.yml"),
)
def mkdocs_cmd(
docs_dir: Path,
nav_file: Path,
template: Optional[Path],
out: Path,
) -> None:
...

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.

11
docforge/nav/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from .spec import NavSpec, load_nav_spec
from .resolver import ResolvedNav, resolve_nav
from .mkdocs import MkDocsNavEmitter
__all__ = [
"NavSpec",
"ResolvedNav",
"MkDocsNavEmitter",
"resolve_nav",
"load_nav_spec",
]

11
docforge/nav/__init__.pyi Normal file
View File

@@ -0,0 +1,11 @@
from .spec import NavSpec, load_nav_spec
from .resolver import ResolvedNav, resolve_nav
from .mkdocs import MkDocsNavEmitter
__all__ = [
"NavSpec",
"ResolvedNav",
"MkDocsNavEmitter",
"resolve_nav",
"load_nav_spec",
]

34
docforge/nav/mkdocs.py Normal file
View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
from typing import List, Dict, Any
from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""Emit MkDocs-compatible nav structures."""
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
# Home entry (semantic path)
if nav.home:
result.append({"Home": nav.home})
# Group entries
for group, paths in nav.groups.items():
entries: List[str] = []
for p in paths:
# Convert filesystem path back to docs-relative path
entries.append(self._to_relative(p))
result.append({group: entries})
return result
def _to_relative(self, path: Path) -> str:
"""
Convert a filesystem path to a docs-relative path.
"""
# Normalize to POSIX-style for MkDocs
return path.as_posix().split("/docs/", 1)[-1]

34
docforge/nav/mkdocs.pyi Normal file
View File

@@ -0,0 +1,34 @@
from typing import Dict, List, Any
from pathlib import Path
from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""
Converts a ResolvedNav into MkDocs-compatible `nav` data.
"""
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
"""
Emit a structure suitable for insertion into mkdocs.yml.
Example return value:
[
{"Home": "openapi_first/index.md"},
{
"Core": [
{"OpenAPI-First App": "openapi_first/app.md"},
...
]
}
]
"""
...
def _to_relative(self, path: Path) -> str:
"""
Convert a filesystem path to a docs-relative path.
"""
...

71
docforge/nav/resolver.py Normal file
View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, Iterable, List
import glob
from docforge.nav.spec import NavSpec
class ResolvedNav:
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
docs_root: Path | None = None,
) -> None:
self.home = home
self.groups = groups
self._docs_root = docs_root
def all_files(self) -> Iterable[Path]:
if self.home:
if self._docs_root is None:
raise RuntimeError("docs_root is required to resolve home path")
yield self._docs_root / self.home
for paths in self.groups.values():
for p in paths:
yield p
def resolve_nav(
spec: NavSpec,
docs_root: Path,
) -> ResolvedNav:
if not docs_root.exists():
raise FileNotFoundError(docs_root)
def resolve_pattern(pattern: str) -> List[Path]:
full = docs_root / pattern
matches = sorted(
Path(p) for p in glob.glob(str(full), recursive=True)
)
if not matches:
raise FileNotFoundError(pattern)
return matches
# Resolve home
home: str | None = None
if spec.home:
home_path = docs_root / spec.home
if not home_path.exists():
raise FileNotFoundError(spec.home)
home = spec.home
# Resolve groups
resolved_groups: Dict[str, List[Path]] = {}
for group, patterns in spec.groups.items():
files: List[Path] = []
for pattern in patterns:
files.extend(resolve_pattern(pattern))
resolved_groups[group] = files
return ResolvedNav(
home=home,
groups=resolved_groups,
docs_root=docs_root,
)

52
docforge/nav/resolver.pyi Normal file
View File

@@ -0,0 +1,52 @@
from pathlib import Path
from typing import Dict, List, Iterable, Optional
from docforge.nav.spec import NavSpec
class ResolvedNav:
"""
Fully-resolved navigation tree.
- `home` is a semantic, docs-rootrelative path (string)
- `groups` contain resolved filesystem Paths
- Order is preserved
"""
home: Optional[str]
groups: Dict[str, List[Path]]
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
docs_root: Path | None = None,
) -> None:
self._docs_root = None
...
def all_files(self) -> Iterable[Path]:
"""
Return all resolved documentation files in nav order.
Includes the home file (resolved against docs_root)
followed by all group files.
"""
...
def resolve_nav(
spec: NavSpec,
docs_root: Path,
) -> ResolvedNav:
"""
Resolve a NavSpec against a docs directory.
Expands wildcards, validates existence, and
resolves filesystem paths relative to docs_root.
Raises:
FileNotFoundError: if any referenced file is missing
ValueError: if resolution fails
"""
...

69
docforge/nav/spec.py Normal file
View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional
import yaml
class NavSpec:
"""Parsed representation of docforge.nav.yml."""
def __init__(
self,
home: Optional[str],
groups: Dict[str, List[str]],
) -> None:
self.home = home
self.groups = groups
@classmethod
def load(cls, path: Path) -> "NavSpec":
if not path.exists():
raise FileNotFoundError(path)
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Nav spec must be a mapping")
home = data.get("home")
groups = data.get("groups", {})
if home is not None and not isinstance(home, str):
raise ValueError("home must be a string")
if not isinstance(groups, dict):
raise ValueError("groups must be a mapping")
for key, value in groups.items():
if not isinstance(key, str):
raise ValueError("group names must be strings")
if not isinstance(value, list) or not all(
isinstance(v, str) for v in value
):
raise ValueError(f"group '{key}' must be a list of strings")
return cls(home=home, groups=groups)
def all_patterns(self) -> List[str]:
patterns: List[str] = []
if self.home:
patterns.append(self.home)
for items in self.groups.values():
patterns.extend(items)
return patterns
def load_nav_spec(path: Path) -> NavSpec:
if not path.exists():
raise FileNotFoundError(path)
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Nav spec must be a YAML mapping")
return NavSpec(
home=data.get("home"),
groups=data.get("groups", {}),
)

42
docforge/nav/spec.pyi Normal file
View File

@@ -0,0 +1,42 @@
from pathlib import Path
from typing import Dict, List, Optional
class NavSpec:
"""
Parsed representation of `docforge.nav.yml`.
This object represents *semantic intent* and is independent
of filesystem structure or MkDocs specifics.
"""
home: Optional[str]
groups: Dict[str, List[str]]
def __init__(
self,
home: Optional[str],
groups: Dict[str, List[str]],
) -> None:
...
@classmethod
def load(cls, path: Path) -> "NavSpec":
"""
Load and validate a nav specification from YAML.
Raises:
FileNotFoundError: if the file does not exist
ValueError: if the schema is invalid
"""
...
def all_patterns(self) -> List[str]:
"""
Return all path patterns referenced by the spec
(including home and group entries).
"""
...
def load_nav_spec(path: Path) -> NavSpec: ...

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

View File

@@ -0,0 +1,33 @@
theme:
name: material
palette:
- scheme: slate
primary: deep purple
accent: cyan
font:
text: Inter
code: JetBrains Mono
features:
- navigation.tabs
- navigation.expand
- navigation.top
- navigation.instant
- content.code.copy
- content.code.annotate
plugins:
- search
- mkdocstrings:
handlers:
python:
paths: ["."]
options:
docstring_style: google
show_source: false
show_signature_annotations: true
separate_signature: true
merge_init_into_class: true
inherited_members: true
annotations_path: brief
show_root_heading: true
group_by_category: true

View File

@@ -1,34 +1,45 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "doc-forge"
version = "0.1.0"
version = "0.0.1"
description = "A renderer-agnostic Python documentation compiler"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{name = "doc-forge team"},
{ name = "Aetos Skia", email = "dev@aetoskia.com" }
]
maintainers = [
{ name = "Aetos Skia", email = "dev@aetoskia.com" }
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Framework :: FastAPI",
"Topic :: Software Development :: Libraries",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
]
requires-python = ">=3.8"
dependencies = [
"griffe>=0.45.0",
"click>=8.0.0",
"pydantic>=2.0.0",
]
[project.scripts]
doc-forge = "docforge.cli.main:main"
[project.optional-dependencies]
mkdocs = [
"mkdocs>=1.5.0",
@@ -49,38 +60,28 @@ dev = [
"mypy>=1.0.0",
]
[project.scripts]
doc-forge = "docforge.cli.main:main"
[project.urls]
Homepage = "https://github.com/doc-forge/doc-forge"
Repository = "https://github.com/doc-forge/doc-forge"
Documentation = "https://doc-forge.readthedocs.io"
Homepage = "https://git.aetoskia.com/aetos/doc-forge"
Documentation = "https://git.aetoskia.com/aetos/doc-forge#readme"
Repository = "https://git.aetoskia.com/aetos/doc-forge.git"
Issues = "https://git.aetoskia.com/aetos/doc-forge/issues"
Versions = "https://git.aetoskia.com/aetos/doc-forge/tags"
[tool.hatch.build.targets.wheel]
packages = ["docforge"]
[tool.black]
line-length = 88
target-version = ['py38']
[tool.setuptools]
packages = { find = { include = ["docforge*"] } }
[tool.setuptools.package-data]
docforge = ["templates/*.yml"]
[tool.ruff]
line-length = 88
target-version = "py38"
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "DTZ", "T10", "EM", "EXE", "ISC", "ICN", "G", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SIM", "TID", "TCH", "ARG", "PTH", "ERA", "PGH", "PL", "TRY", "NPY", "RUF"]
ignore = ["E501"]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
python_version = "3.10"
strict = true
ignore_missing_imports = true

140
tests/cli/test_mkdocs.py Normal file
View File

@@ -0,0 +1,140 @@
from pathlib import Path
import yaml
from click.testing import CliRunner
from docforge.cli.main import cli
def _write_nav_spec(path: Path) -> None:
path.write_text(
"""
home: openapi_first/index.md
groups:
Core:
- openapi_first/app.md
- openapi_first/client.md
""".strip(),
encoding="utf-8",
)
def _write_docs(docs: Path) -> None:
files = [
"openapi_first/index.md",
"openapi_first/app.md",
"openapi_first/client.md",
]
for rel in files:
p = docs / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(f"# {rel}", encoding="utf-8")
def test_mkdocs_uses_builtin_template(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
nav_file = tmp_path / "docforge.nav.yml"
out_file = tmp_path / "mkdocs.yml"
_write_docs(docs)
_write_nav_spec(nav_file)
runner = CliRunner()
result = runner.invoke(
cli,
[
"mkdocs",
"--docs-dir",
str(docs),
"--nav",
str(nav_file),
"--out",
str(out_file),
],
)
assert result.exit_code == 0
assert out_file.exists()
data = yaml.safe_load(out_file.read_text(encoding="utf-8"))
# Nav should be injected
assert "nav" in data
assert data["nav"] == [
{"Home": "openapi_first/index.md"},
{
"Core": [
"openapi_first/app.md",
"openapi_first/client.md",
]
},
]
# Template content should still exist
assert "theme" in data
assert "plugins" in data
def test_mkdocs_overrides_template(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
nav_file = tmp_path / "docforge.nav.yml"
template = tmp_path / "custom.yml"
out_file = tmp_path / "mkdocs.yml"
_write_docs(docs)
_write_nav_spec(nav_file)
template.write_text(
"""
site_name: Custom Site
theme:
name: readthedocs
""".strip(),
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"mkdocs",
"--docs-dir",
str(docs),
"--nav",
str(nav_file),
"--template",
str(template),
"--out",
str(out_file),
],
)
assert result.exit_code == 0
data = yaml.safe_load(out_file.read_text(encoding="utf-8"))
assert data["site_name"] == "Custom Site"
assert data["theme"]["name"] == "readthedocs"
assert "nav" in data
def test_mkdocs_missing_nav_fails(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
runner = CliRunner()
result = runner.invoke(
cli,
[
"mkdocs",
"--docs-dir",
str(docs),
],
)
assert result.exit_code != 0
assert "Nav spec not found" in result.output

0
tests/nav/__init__.py Normal file
View File

37
tests/nav/test_mkdocs.py Normal file
View File

@@ -0,0 +1,37 @@
from pathlib import Path
from docforge.nav import ResolvedNav
from docforge.nav import MkDocsNavEmitter
def test_emit_mkdocs_nav():
nav = ResolvedNav(
home="openapi_first/index.md",
groups={
"Core": [
Path("openapi_first/app.md"),
Path("openapi_first/client.md"),
],
"CLI": [
Path("openapi_first/cli.md"),
],
},
)
emitter = MkDocsNavEmitter()
mkdocs_nav = emitter.emit(nav)
assert mkdocs_nav == [
{"Home": "openapi_first/index.md"},
{
"Core": [
"openapi_first/app.md",
"openapi_first/client.md",
]
},
{
"CLI": [
"openapi_first/cli.md",
]
},
]

104
tests/nav/test_resolver.py Normal file
View File

@@ -0,0 +1,104 @@
from pathlib import Path
import pytest
from docforge.nav import NavSpec
from docforge.nav import resolve_nav
def _write_docs(root: Path, paths: list[str]) -> None:
for p in paths:
full = root / p
full.parent.mkdir(parents=True, exist_ok=True)
full.write_text(f"# {p}", encoding="utf-8")
def test_resolve_simple_nav(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"openapi_first/index.md",
"openapi_first/app.md",
"openapi_first/client.md",
],
)
spec = NavSpec(
home="openapi_first/index.md",
groups={
"Core": [
"openapi_first/app.md",
"openapi_first/client.md",
]
},
)
resolved = resolve_nav(spec, docs)
assert resolved.home == "openapi_first/index.md"
assert list(resolved.groups["Core"]) == [
docs / "openapi_first/app.md",
docs / "openapi_first/client.md",
]
def test_wildcard_expansion_preserves_order(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"pkg/a.md",
"pkg/b.md",
"pkg/c.md",
],
)
spec = NavSpec(
home=None,
groups={"All": ["pkg/*.md"]},
)
resolved = resolve_nav(spec, docs)
paths = [p.name for p in resolved.groups["All"]]
assert paths == ["a.md", "b.md", "c.md"]
def test_missing_doc_file_raises(tmp_path: Path):
docs = tmp_path / "docs"
docs.mkdir()
spec = NavSpec(
home="missing.md",
groups={},
)
with pytest.raises(FileNotFoundError):
resolve_nav(spec, docs)
def test_all_files_returns_flat_sequence(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"a.md",
"b.md",
"c.md",
],
)
spec = NavSpec(
home="a.md",
groups={"G": ["b.md", "c.md"]},
)
resolved = resolve_nav(spec, docs)
files = list(resolved.all_files())
assert files == [
docs / "a.md",
docs / "b.md",
docs / "c.md",
]

71
tests/nav/test_spec.py Normal file
View File

@@ -0,0 +1,71 @@
from pathlib import Path
import pytest
from docforge.nav import NavSpec
def test_load_valid_nav_spec(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
home: openapi_first/index.md
groups:
Core:
- openapi_first/app.md
- openapi_first/client.md
CLI:
- openapi_first/cli.md
""",
encoding="utf-8",
)
spec = NavSpec.load(nav_yml)
assert spec.home == "openapi_first/index.md"
assert "Core" in spec.groups
assert spec.groups["Core"] == [
"openapi_first/app.md",
"openapi_first/client.md",
]
def test_missing_file_raises(tmp_path: Path):
with pytest.raises(FileNotFoundError):
NavSpec.load(tmp_path / "missing.yml")
def test_invalid_schema_raises(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
groups:
- not_a_mapping
""",
encoding="utf-8",
)
with pytest.raises(ValueError):
NavSpec.load(nav_yml)
def test_all_patterns_includes_home_and_groups(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
home: a.md
groups:
X:
- b.md
- c/*.md
""",
encoding="utf-8",
)
spec = NavSpec.load(nav_yml)
patterns = spec.all_patterns()
assert "a.md" in patterns
assert "b.md" in patterns
assert "c/*.md" in patterns

View File

@@ -0,0 +1,61 @@
from pathlib import Path
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()
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)
emitted = {
p.relative_to(tmp_path).as_posix()
for p in tmp_path.rglob("*.md")
}
module_paths = [m.path for m in project.get_all_modules()]
expected = set()
for path in module_paths:
parts = path.split(".")
# treat package as index.md if any other module is nested under it
is_package = any(
other != path and other.startswith(path + ".")
for other in module_paths
)
if is_package:
expected.add("/".join(parts) + "/index.md")
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}"