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. 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,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
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__ = [ __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.

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

@@ -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] [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"
license = {text = "MIT"} requires-python = ">=3.10"
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
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}"