Compare commits
8 Commits
778a986262
...
dca19caaf3
| Author | SHA1 | Date | |
|---|---|---|---|
| dca19caaf3 | |||
| 2e5d330fca | |||
| 5073f9d73f | |||
| 94b90f2ccf | |||
| a8ba02c57b | |||
| 726e7ca6d2 | |||
| 869b1730c4 | |||
| 09aca78ba1 |
129
.drone.yml
Normal file
129
.drone.yml
Normal 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
91
.gitignore
vendored
@@ -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.*
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@@ -15,6 +16,8 @@ def cli() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(mkdocs_cmd)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# tree
|
# tree
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
@@ -80,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)
|
||||||
|
|||||||
84
docforge/cli/mkdocs.py
Normal file
84
docforge/cli/mkdocs.py
Normal 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
41
docforge/cli/mkdocs.pyi
Normal 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:
|
||||||
|
...
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from .griffe_loader import GriffeLoader
|
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GriffeLoader"
|
"GriffeLoader",
|
||||||
|
"discover_module_paths",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .griffe_loader import GriffeLoader
|
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GriffeLoader"
|
"GriffeLoader",
|
||||||
|
"discover_module_paths",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
11
docforge/nav/__init__.py
Normal file
11
docforge/nav/__init__.py
Normal 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
11
docforge/nav/__init__.pyi
Normal 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
34
docforge/nav/mkdocs.py
Normal 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
34
docforge/nav/mkdocs.pyi
Normal 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
71
docforge/nav/resolver.py
Normal 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
52
docforge/nav/resolver.pyi
Normal 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-root–relative 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
69
docforge/nav/spec.py
Normal 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
42
docforge/nav/spec.pyi
Normal 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: ...
|
||||||
@@ -11,42 +11,42 @@ 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():
|
|
||||||
if md_path.read_text(encoding="utf-8") == content:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
md_path.write_text(content, encoding="utf-8")
|
md_path.write_text(content, encoding="utf-8")
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|||||||
33
docforge/templates/mkdocs.sample.yml
Normal file
33
docforge/templates/mkdocs.sample.yml
Normal 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
|
||||||
@@ -1,34 +1,45 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["setuptools>=68", "wheel"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "doc-forge"
|
name = "doc-forge"
|
||||||
version = "0.1.0"
|
version = "0.0.1"
|
||||||
description = "A renderer-agnostic Python documentation compiler"
|
description = "A renderer-agnostic Python documentation compiler"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
{name = "doc-forge team"},
|
{ name = "Aetos Skia", email = "dev@aetoskia.com" }
|
||||||
|
]
|
||||||
|
maintainers = [
|
||||||
|
{ name = "Aetos Skia", email = "dev@aetoskia.com" }
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Framework :: FastAPI",
|
||||||
|
"Topic :: Software Development :: Libraries",
|
||||||
|
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.8"
|
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"griffe>=0.45.0",
|
"griffe>=0.45.0",
|
||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
doc-forge = "docforge.cli.main:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mkdocs = [
|
mkdocs = [
|
||||||
"mkdocs>=1.5.0",
|
"mkdocs>=1.5.0",
|
||||||
@@ -49,38 +60,28 @@ dev = [
|
|||||||
"mypy>=1.0.0",
|
"mypy>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
doc-forge = "docforge.cli.main:main"
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/doc-forge/doc-forge"
|
Homepage = "https://git.aetoskia.com/aetos/doc-forge"
|
||||||
Repository = "https://github.com/doc-forge/doc-forge"
|
Documentation = "https://git.aetoskia.com/aetos/doc-forge#readme"
|
||||||
Documentation = "https://doc-forge.readthedocs.io"
|
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]
|
[tool.setuptools]
|
||||||
line-length = 88
|
packages = { find = { include = ["docforge*"] } }
|
||||||
target-version = ['py38']
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
docforge = ["templates/*.yml"]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 100
|
||||||
target-version = "py38"
|
target-version = "py310"
|
||||||
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"]
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.8"
|
python_version = "3.10"
|
||||||
warn_return_any = true
|
strict = true
|
||||||
warn_unused_configs = true
|
ignore_missing_imports = 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
|
|
||||||
|
|||||||
140
tests/cli/test_mkdocs.py
Normal file
140
tests/cli/test_mkdocs.py
Normal 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
0
tests/nav/__init__.py
Normal file
37
tests/nav/test_mkdocs.py
Normal file
37
tests/nav/test_mkdocs.py
Normal 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
104
tests/nav/test_resolver.py
Normal 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
71
tests/nav/test_spec.py
Normal 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
|
||||||
61
tests/renderers/test_mkdocs_module_coverage.py
Normal file
61
tests/renderers/test_mkdocs_module_coverage.py
Normal 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}"
|
||||||
Reference in New Issue
Block a user