Compare commits
22 Commits
778a986262
...
0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e5114532 | |||
| 81e8a8cd49 | |||
| be8f23c8ab | |||
| 9392d2c999 | |||
| 9d0b6e78d1 | |||
| 4fa3bc0533 | |||
| 46b7cc52e1 | |||
| c8ecc6a476 | |||
| 5c8d9dcc9c | |||
| b497c5d2e9 | |||
| 0061dbe2eb | |||
| 6f9776dff2 | |||
| 6c9fb433cb | |||
| 6b334fd181 | |||
| 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
|
||||
90
.gitignore
vendored
90
.gitignore
vendored
@@ -1 +1,89 @@
|
||||
.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
|
||||
# =========================
|
||||
site/
|
||||
.build/
|
||||
_doctrees/
|
||||
|
||||
# =========================
|
||||
# IDEs & editors
|
||||
# =========================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.bak
|
||||
*.tmp
|
||||
|
||||
# =========================
|
||||
# OS-specific
|
||||
# =========================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# =========================
|
||||
# Logs
|
||||
# =========================
|
||||
*.log
|
||||
|
||||
# =========================
|
||||
# Local config overrides
|
||||
# =========================
|
||||
.env
|
||||
.env.*
|
||||
|
||||
23
docforge.nav.yml
Normal file
23
docforge.nav.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
home: docforge/index.md
|
||||
groups:
|
||||
Loader:
|
||||
- docforge/loader/index.md
|
||||
- docforge/loader/griffe_loader.md
|
||||
Model:
|
||||
- docforge/model/index.md
|
||||
- docforge/model/module.md
|
||||
- docforge/model/object.md
|
||||
- docforge/model/project.md
|
||||
Navigation:
|
||||
- docforge/nav/index.md
|
||||
- docforge/nav/spec.md
|
||||
- docforge/nav/resolver.md
|
||||
- docforge/nav/mkdocs.md
|
||||
Renderers:
|
||||
- docforge/renderers/index.md
|
||||
- docforge/renderers/base.md
|
||||
- docforge/renderers/mkdocs.md
|
||||
CLI:
|
||||
- docforge/cli/index.md
|
||||
- docforge/cli/main.md
|
||||
- docforge/cli/mkdocs.md
|
||||
@@ -1,18 +1,64 @@
|
||||
"""
|
||||
doc-forge — renderer-agnostic Python documentation compiler.
|
||||
# doc-forge
|
||||
|
||||
At this stage, doc-forge publicly exposes only the Introspection Layer.
|
||||
All the rendering, exporting, and serving APIs are intentionally private
|
||||
until their contracts are finalized.
|
||||
`doc-forge` is a renderer-agnostic Python documentation compiler designed for
|
||||
speed, flexibility, and beautiful output. It decouples the introspection of
|
||||
your code from the rendering process, allowing you to generate documentation
|
||||
for various platforms (starting with MkDocs) from a single internal model.
|
||||
|
||||
## Installation
|
||||
|
||||
Install using `pip` with the optional `mkdocs` dependencies for a complete setup:
|
||||
|
||||
```bash
|
||||
pip install doc-forge
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Generate Markdown Sources**:
|
||||
Introspect your package and create ready-to-use Markdown files:
|
||||
```bash
|
||||
doc-forge generate --module my_package --docs-dir docs
|
||||
```
|
||||
|
||||
2. **Define Navigation**:
|
||||
Create a `docforge.nav.yml` to organize your documentation:
|
||||
```yaml
|
||||
home: my_package/index.md
|
||||
groups:
|
||||
Core API:
|
||||
- my_package/core/*.md
|
||||
Utilities:
|
||||
- my_package/utils.md
|
||||
```
|
||||
|
||||
3. **Generate MkDocs Configuration**:
|
||||
```bash
|
||||
doc-forge mkdocs --site-name "My Awesome Docs"
|
||||
```
|
||||
|
||||
4. **Preview**:
|
||||
```bash
|
||||
doc-forge serve
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `docforge.loader`: Introspects source code using static analysis (`griffe`).
|
||||
- `docforge.model`: The internal representation of your project, modules, and objects.
|
||||
- `docforge.renderers`: Converters that turn the model into physical files.
|
||||
- `docforge.nav`: Managers for logical-to-physical path mapping and navigation.
|
||||
"""
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
"""
|
||||
# CLI Layer
|
||||
|
||||
The `docforge.cli` package provides the command-line interface for interacting
|
||||
with doc-forge.
|
||||
|
||||
## Available Commands
|
||||
|
||||
- **tree**: Visualize the introspected project structure.
|
||||
- **generate**: Create Markdown source files from Python code.
|
||||
- **mkdocs**: Generate the primary `mkdocs.yml` configuration.
|
||||
- **build**: Build the final documentation site.
|
||||
- **serve**: Launch a local development server with live-reloading.
|
||||
"""
|
||||
|
||||
from .main import main
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
Main entry point for the doc-forge CLI. This module defines the core command
|
||||
group and the 'tree', 'generate', 'build', and 'serve' commands.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
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()
|
||||
def cli() -> None:
|
||||
"""doc-forge command-line interface."""
|
||||
"""
|
||||
doc-forge CLI: A tool for introspecting Python projects and generating
|
||||
documentation.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(mkdocs_cmd)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# tree
|
||||
# ---------------------------------------------------------------------
|
||||
@@ -34,7 +43,13 @@ def tree(
|
||||
modules: Sequence[str],
|
||||
project_name: Optional[str],
|
||||
) -> None:
|
||||
"""Show introspection tree."""
|
||||
"""
|
||||
Visualize the project structure including modules and their members.
|
||||
|
||||
Args:
|
||||
modules: List of module paths to introspect.
|
||||
project_name: Optional project name override.
|
||||
"""
|
||||
loader = GriffeLoader()
|
||||
project = loader.load_project(list(modules), project_name)
|
||||
|
||||
@@ -48,6 +63,9 @@ def tree(
|
||||
|
||||
|
||||
def _print_object(obj, indent: str) -> None:
|
||||
"""
|
||||
Recursive helper to print doc objects.
|
||||
"""
|
||||
click.echo(f"{indent}├── {obj.name}")
|
||||
for member in obj.get_all_members():
|
||||
_print_object(member, indent + "│ ")
|
||||
@@ -59,8 +77,7 @@ def _print_object(obj, indent: str) -> None:
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--modules",
|
||||
multiple=True,
|
||||
"--module",
|
||||
required=True,
|
||||
help="Python module import paths to document",
|
||||
)
|
||||
@@ -74,13 +91,26 @@ def _print_object(obj, indent: str) -> None:
|
||||
default=Path("docs"),
|
||||
)
|
||||
def generate(
|
||||
modules: Sequence[str],
|
||||
module: str,
|
||||
project_name: Optional[str],
|
||||
docs_dir: Path,
|
||||
) -> None:
|
||||
"""Generate documentation source files using MkDocs renderer."""
|
||||
"""
|
||||
Generate Markdown source files for the specified module.
|
||||
|
||||
Args:
|
||||
module: The primary module path to document.
|
||||
project_name: Optional project name override.
|
||||
docs_dir: Directory where documentation sources will be written.
|
||||
"""
|
||||
loader = GriffeLoader()
|
||||
project = loader.load_project(list(modules), project_name)
|
||||
discovered_paths = discover_module_paths(
|
||||
module,
|
||||
)
|
||||
project = loader.load_project(
|
||||
discovered_paths,
|
||||
project_name
|
||||
)
|
||||
|
||||
renderer = MkDocsRenderer()
|
||||
renderer.generate_sources(project, docs_dir)
|
||||
@@ -99,7 +129,12 @@ def generate(
|
||||
default=Path("mkdocs.yml"),
|
||||
)
|
||||
def build(mkdocs_yml: Path) -> None:
|
||||
"""Build documentation using MkDocs."""
|
||||
"""
|
||||
Build the documentation site using MkDocs.
|
||||
|
||||
Args:
|
||||
mkdocs_yml: Path to the mkdocs.yml configuration file.
|
||||
"""
|
||||
if not mkdocs_yml.exists():
|
||||
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
|
||||
|
||||
@@ -122,11 +157,22 @@ def build(mkdocs_yml: Path) -> None:
|
||||
default=Path("mkdocs.yml"),
|
||||
)
|
||||
def serve(mkdocs_yml: Path) -> None:
|
||||
"""Serve documentation using MkDocs."""
|
||||
"""
|
||||
Serve the documentation site with live-reload using MkDocs.
|
||||
|
||||
Args:
|
||||
mkdocs_yml: Path to the mkdocs.yml configuration file.
|
||||
"""
|
||||
if not mkdocs_yml.exists():
|
||||
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
|
||||
|
||||
from mkdocs.commands.serve import serve as mkdocs_serve
|
||||
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
url = f"http://{host}:{port}/"
|
||||
|
||||
click.echo(f"Serving documentation at {url}")
|
||||
mkdocs_serve(config_file=str(mkdocs_yml))
|
||||
|
||||
|
||||
@@ -135,6 +181,9 @@ def serve(mkdocs_yml: Path) -> None:
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
CLI Entry point.
|
||||
"""
|
||||
cli()
|
||||
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ def tree(
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--modules",
|
||||
multiple=True,
|
||||
"--module",
|
||||
help="Python module import paths to document",
|
||||
)
|
||||
@click.option(
|
||||
@@ -42,7 +41,7 @@ def tree(
|
||||
default=Path("docs"),
|
||||
)
|
||||
def generate(
|
||||
modules: Sequence[str],
|
||||
module: str,
|
||||
project_name: str | None,
|
||||
docs_dir: Path,
|
||||
) -> None:
|
||||
|
||||
116
docforge/cli/mkdocs.py
Normal file
116
docforge/cli/mkdocs.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
This module contains the 'mkdocs' CLI command, which orchestrates the generation
|
||||
of the main mkdocs.yml configuration file.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Load a YAML template for mkdocs.yml. If no template is provided,
|
||||
loads the built-in sample template.
|
||||
|
||||
Args:
|
||||
template: Path to the template file, or None.
|
||||
|
||||
Returns:
|
||||
The loaded template data as a dictionary.
|
||||
"""
|
||||
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(
|
||||
"--site-name",
|
||||
required=True,
|
||||
help="MkDocs site_name (required)",
|
||||
)
|
||||
@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,
|
||||
site_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Generate an mkdocs.yml configuration file by combining a template with
|
||||
the navigation structure resolved from a docforge.nav.yml file.
|
||||
|
||||
Args:
|
||||
docs_dir: Path to the directory containing documentation Markdown files.
|
||||
nav_file: Path to the docforge.nav.yml specification.
|
||||
template: Optional path to an mkdocs.yml template.
|
||||
out: Path where the final mkdocs.yml will be written.
|
||||
site_name: The name of the documentation site.
|
||||
"""
|
||||
|
||||
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 site_name
|
||||
data["site_name"] = site_name
|
||||
|
||||
# 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}")
|
||||
45
docforge/cli/mkdocs.pyi
Normal file
45
docforge/cli/mkdocs.pyi
Normal file
@@ -0,0 +1,45 @@
|
||||
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(
|
||||
"--site-name",
|
||||
required=True,
|
||||
help="MkDocs site_name (required)",
|
||||
)
|
||||
@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,
|
||||
site_name: str,
|
||||
) -> None:
|
||||
...
|
||||
@@ -1,5 +1,22 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
"""
|
||||
# Loader Layer
|
||||
|
||||
The `docforge.loader` package is responsible for discovering Python source files
|
||||
and extracting their documentation using static analysis.
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Discovery**: Automatically find all modules and packages in a project
|
||||
directory.
|
||||
- **Introspection**: Uses `griffe` to parse docstrings, signatures, and
|
||||
hierarchical relationships without executing the code.
|
||||
- **Filtering**: Automatically excludes private members (prefixed with `_`) to
|
||||
ensure clean public documentation.
|
||||
"""
|
||||
|
||||
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||
|
||||
__all__ = [
|
||||
"GriffeLoader"
|
||||
"GriffeLoader",
|
||||
"discover_module_paths",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .griffe_loader import GriffeLoader
|
||||
from .griffe_loader import GriffeLoader, discover_module_paths
|
||||
|
||||
__all__ = [
|
||||
"GriffeLoader"
|
||||
"GriffeLoader",
|
||||
"discover_module_paths",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module provides the GriffeLoader, which uses the 'griffe' library to
|
||||
introspect Python source code and populate the doc-forge Project model.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from griffe import (
|
||||
@@ -16,10 +20,57 @@ 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 is treated as a package.
|
||||
- Any .py file is treated as a module.
|
||||
- All paths are converted to dotted module paths.
|
||||
|
||||
Args:
|
||||
module_name: The name of the package to discover.
|
||||
project_root: The root directory of the project. Defaults to current working directory.
|
||||
|
||||
Returns:
|
||||
A sorted list of 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."""
|
||||
"""
|
||||
Loads Python modules and extracts documentation using the Griffe introspection engine.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize the GriffeLoader.
|
||||
"""
|
||||
self._loader = _GriffeLoader(
|
||||
modules_collection=ModulesCollection(),
|
||||
lines_collection=LinesCollection(),
|
||||
@@ -29,7 +80,19 @@ class GriffeLoader:
|
||||
self,
|
||||
module_paths: List[str],
|
||||
project_name: Optional[str] = None,
|
||||
skip_import_errors: bool = None,
|
||||
) -> Project:
|
||||
"""
|
||||
Load multiple modules and combine them into a single Project model.
|
||||
|
||||
Args:
|
||||
module_paths: A list of dotted paths to the modules to load.
|
||||
project_name: Optional name for the project. Defaults to the first module name.
|
||||
skip_import_errors: If True, modules that fail to import will be skipped.
|
||||
|
||||
Returns:
|
||||
A Project instance containing the loaded modules.
|
||||
"""
|
||||
if not module_paths:
|
||||
raise ValueError("At least one module path must be provided")
|
||||
|
||||
@@ -41,19 +104,28 @@ class GriffeLoader:
|
||||
for module_path in module_paths:
|
||||
try:
|
||||
module = self.load_module(module_path)
|
||||
project.add_module(module)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load module %s: %s", module_path, e)
|
||||
continue
|
||||
except ImportError as import_error:
|
||||
if skip_import_errors:
|
||||
logger.debug("Could not load %s: %s", module_path, import_error)
|
||||
continue
|
||||
else:
|
||||
raise import_error
|
||||
project.add_module(module)
|
||||
|
||||
return project
|
||||
|
||||
def load_module(self, path: str) -> Module:
|
||||
try:
|
||||
self._loader.load(path)
|
||||
griffe_module = self._loader.modules_collection[path]
|
||||
except Exception as e:
|
||||
raise ImportError(f"Failed to load module '{path}': {e}") from e
|
||||
"""
|
||||
Load a single module and convert its introspection data into the docforge model.
|
||||
|
||||
Args:
|
||||
path: The dotted path of the module to load.
|
||||
|
||||
Returns:
|
||||
A Module instance.
|
||||
"""
|
||||
self._loader.load(path)
|
||||
griffe_module = self._loader.modules_collection[path]
|
||||
|
||||
return self._convert_module(griffe_module)
|
||||
|
||||
@@ -70,10 +142,8 @@ class GriffeLoader:
|
||||
for name, member in obj.members.items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
module.add_object(self._convert_object(member))
|
||||
except Exception as e:
|
||||
logger.warning("Skipping member %s: %s", name, e)
|
||||
|
||||
module.add_object(self._convert_object(member))
|
||||
|
||||
return module
|
||||
|
||||
@@ -89,14 +159,13 @@ class GriffeLoader:
|
||||
docstring=self._safe_docstring(obj),
|
||||
)
|
||||
|
||||
if hasattr(obj, "members"):
|
||||
try:
|
||||
for name, member in obj.members.items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
doc_obj.add_member(self._convert_object(member))
|
||||
except Exception:
|
||||
continue
|
||||
doc_obj.add_member(self._convert_object(member))
|
||||
except AliasResolutionError:
|
||||
pass
|
||||
|
||||
return doc_obj
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,6 +23,7 @@ class GriffeLoader:
|
||||
self,
|
||||
module_paths: List[str],
|
||||
project_name: Optional[str] = ...,
|
||||
skip_import_errors: bool = ...,
|
||||
) -> Project:
|
||||
"""Load a documentation project from Python modules."""
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""
|
||||
Core documentation model for doc-forge.
|
||||
# Model Layer
|
||||
|
||||
These classes form the renderer-agnostic, introspection-derived
|
||||
representation of Python documentation.
|
||||
The `docforge.model` package provides the core data structures used to represent
|
||||
Python source code in a documentation-focused hierarchy.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **Project**: The root container for all documented modules.
|
||||
- **Module**: Represents a Python module or package, containing members.
|
||||
- **DocObject**: A recursive structure for classes, functions, and attributes.
|
||||
|
||||
These classes are designed to be renderer-agnostic, allowing the same internal
|
||||
representation to be transformed into various output formats (currently MkDocs).
|
||||
"""
|
||||
|
||||
from .project import Project
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module defines the Module class, which represents a Python module or package
|
||||
in the doc-forge documentation model. It acts as a container for top-level
|
||||
documented objects.
|
||||
"""
|
||||
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
@@ -6,22 +10,57 @@ from docforge.model.object import DocObject
|
||||
|
||||
|
||||
class Module:
|
||||
"""Represents a documented Python module."""
|
||||
"""
|
||||
Represents a documented Python module or package.
|
||||
|
||||
Attributes:
|
||||
path: Dotted import path of the module.
|
||||
docstring: Module-level docstring content.
|
||||
members: Dictionary mapping object names to their DocObject representations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
docstring: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a new Module.
|
||||
|
||||
Args:
|
||||
path: The dotted path of the module.
|
||||
docstring: The module's docstring, if any.
|
||||
"""
|
||||
self.path = path
|
||||
self.docstring = docstring
|
||||
self.members: Dict[str, DocObject] = {}
|
||||
|
||||
def add_object(self, obj: DocObject) -> None:
|
||||
"""
|
||||
Add a documented object to the module.
|
||||
|
||||
Args:
|
||||
obj: The object to add.
|
||||
"""
|
||||
self.members[obj.name] = obj
|
||||
|
||||
def get_object(self, name: str) -> DocObject:
|
||||
"""
|
||||
Retrieve a member object by name.
|
||||
|
||||
Args:
|
||||
name: The name of the object.
|
||||
|
||||
Returns:
|
||||
The requested DocObject.
|
||||
"""
|
||||
return self.members[name]
|
||||
|
||||
def get_all_objects(self) -> Iterable[DocObject]:
|
||||
"""
|
||||
Get all top-level objects in the module.
|
||||
|
||||
Returns:
|
||||
An iterable of DocObject instances.
|
||||
"""
|
||||
return self.members.values()
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module defines the DocObject class, the fundamental recursive unit of the
|
||||
doc-forge documentation model. A DocObject represents a single Python entity
|
||||
(class, function, method, or attribute) and its nested members.
|
||||
"""
|
||||
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
|
||||
class DocObject:
|
||||
"""Represents a documented Python object."""
|
||||
"""
|
||||
Represents a documented Python object (class, function, method, etc.).
|
||||
|
||||
Attributes:
|
||||
name: Local name of the object.
|
||||
kind: Type of object (e.g., 'class', 'function', 'attribute').
|
||||
path: Full dotted import path to the object.
|
||||
signature: Callable signature, if applicable.
|
||||
docstring: Raw docstring content extracted from the source.
|
||||
members: Dictionary mapping member names to their DocObject representations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -14,18 +28,49 @@ class DocObject:
|
||||
signature: Optional[str] = None,
|
||||
docstring: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a new DocObject.
|
||||
|
||||
Args:
|
||||
name: The local name of the object.
|
||||
kind: The kind of object (e.g., 'class', 'function').
|
||||
path: The full dotted path to the object.
|
||||
signature: The object's signature (for callable objects).
|
||||
docstring: The object's docstring, if any.
|
||||
"""
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.path = path
|
||||
self.signature = signature
|
||||
self.docstring = docstring
|
||||
self.members: Dict[str, DocObject] = {}
|
||||
self.members: Dict[str, 'DocObject'] = {}
|
||||
|
||||
def add_member(self, obj: DocObject) -> None:
|
||||
def add_member(self, obj: 'DocObject') -> None:
|
||||
"""
|
||||
Add a child member to this object (e.g., a method to a class).
|
||||
|
||||
Args:
|
||||
obj: The child DocObject to add.
|
||||
"""
|
||||
self.members[obj.name] = obj
|
||||
|
||||
def get_member(self, name: str) -> DocObject:
|
||||
def get_member(self, name: str) -> 'DocObject':
|
||||
"""
|
||||
Retrieve a child member by name.
|
||||
|
||||
Args:
|
||||
name: The name of the member.
|
||||
|
||||
Returns:
|
||||
The requested DocObject.
|
||||
"""
|
||||
return self.members[name]
|
||||
|
||||
def get_all_members(self) -> Iterable[DocObject]:
|
||||
def get_all_members(self) -> Iterable['DocObject']:
|
||||
"""
|
||||
Get all members of this object.
|
||||
|
||||
Returns:
|
||||
An iterable of child DocObject instances.
|
||||
"""
|
||||
return self.members.values()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module defines the Project class, the top-level container for a documented
|
||||
project. It aggregates multiple Module instances into a single named entity.
|
||||
"""
|
||||
|
||||
from typing import Dict, Iterable
|
||||
|
||||
@@ -6,20 +9,59 @@ from docforge.model.module import Module
|
||||
|
||||
|
||||
class Project:
|
||||
"""Represents a documentation project."""
|
||||
"""
|
||||
Represents a documentation project, serving as a container for modules.
|
||||
|
||||
Attributes:
|
||||
name: Name of the project.
|
||||
modules: Dictionary mapping module paths to Module instances.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""
|
||||
Initialize a new Project.
|
||||
|
||||
Args:
|
||||
name: The name of the project.
|
||||
"""
|
||||
self.name = name
|
||||
self.modules: Dict[str, Module] = {}
|
||||
|
||||
def add_module(self, module: Module) -> None:
|
||||
"""
|
||||
Add a module to the project.
|
||||
|
||||
Args:
|
||||
module: The module to add.
|
||||
"""
|
||||
self.modules[module.path] = module
|
||||
|
||||
def get_module(self, path: str) -> Module:
|
||||
"""
|
||||
Retrieve a module by its dotted path.
|
||||
|
||||
Args:
|
||||
path: The dotted path of the module (e.g., 'pkg.mod').
|
||||
|
||||
Returns:
|
||||
The requested Module.
|
||||
"""
|
||||
return self.modules[path]
|
||||
|
||||
def get_all_modules(self) -> Iterable[Module]:
|
||||
"""
|
||||
Get all modules in the project.
|
||||
|
||||
Returns:
|
||||
An iterable of Module objects.
|
||||
"""
|
||||
return self.modules.values()
|
||||
|
||||
def get_module_list(self) -> list[str]:
|
||||
"""
|
||||
Get the list of all module dotted paths.
|
||||
|
||||
Returns:
|
||||
A list of module paths.
|
||||
"""
|
||||
return list(self.modules.keys())
|
||||
|
||||
27
docforge/nav/__init__.py
Normal file
27
docforge/nav/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
# Navigation Layer
|
||||
|
||||
The `docforge.nav` package manages the mapping between the logical documentation
|
||||
structure and the physical files on disk.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Spec Definition**: Users define navigation intent in `docforge.nav.yml`.
|
||||
2. **Resolution**: `resolve_nav` matches patterns in the spec to generated `.md` files.
|
||||
3. **Emission**: `MkDocsNavEmitter` produces the final YAML structure for `mkdocs.yml`.
|
||||
|
||||
This abstraction allows doc-forge to support complex grouping and ordering
|
||||
independently of the source code's physical layout.
|
||||
"""
|
||||
|
||||
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",
|
||||
]
|
||||
72
docforge/nav/mkdocs.py
Normal file
72
docforge/nav/mkdocs.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
This module provides the MkDocsNavEmitter, which converts a ResolvedNav instance
|
||||
into the specific YAML-ready list structure expected by the MkDocs 'nav'
|
||||
configuration.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from docforge.nav.resolver import ResolvedNav
|
||||
|
||||
|
||||
class MkDocsNavEmitter:
|
||||
"""
|
||||
Emitter responsible for transforming a ResolvedNav into an MkDocs-compatible
|
||||
navigation structure.
|
||||
"""
|
||||
|
||||
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate a list of navigation entries for mkdocs.yml.
|
||||
|
||||
Args:
|
||||
nav: The resolved navigation data.
|
||||
|
||||
Returns:
|
||||
A list of dictionary mappings representing the MkDocs navigation.
|
||||
"""
|
||||
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
|
||||
rel_path = self._to_relative(p, nav._docs_root)
|
||||
entries.append(rel_path)
|
||||
result.append({group: entries})
|
||||
|
||||
return result
|
||||
|
||||
def _to_relative(self, path: Path, docs_root: Path | None) -> str:
|
||||
"""
|
||||
Convert a filesystem path to a path relative to the documentation root.
|
||||
This handles both absolute and relative filesystem paths, ensuring the
|
||||
output is compatible with MkDocs navigation requirements.
|
||||
|
||||
Args:
|
||||
path: The path to convert.
|
||||
docs_root: The root directory for documentation.
|
||||
|
||||
Returns:
|
||||
A string representing the relative POSIX-style path.
|
||||
"""
|
||||
if docs_root and path.is_absolute():
|
||||
try:
|
||||
path = path.relative_to(docs_root.resolve())
|
||||
except ValueError:
|
||||
pass
|
||||
elif docs_root:
|
||||
# Handle relative paths (e.g. starting with 'docs/')
|
||||
path_str = path.as_posix()
|
||||
docs_root_str = docs_root.as_posix()
|
||||
if path_str.startswith(docs_root_str + "/"):
|
||||
return path_str[len(docs_root_str) + 1:]
|
||||
|
||||
# Fallback for other cases
|
||||
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, docs_root: Path | None) -> str:
|
||||
"""
|
||||
Convert a filesystem path to a docs-relative path.
|
||||
"""
|
||||
...
|
||||
112
docforge/nav/resolver.py
Normal file
112
docforge/nav/resolver.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
This module contains the logic for resolving a NavSpec against the physical
|
||||
filesystem. It expands globs and validates that all referenced documents
|
||||
actually exist on disk.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
import glob
|
||||
|
||||
from docforge.nav.spec import NavSpec
|
||||
|
||||
|
||||
class ResolvedNav:
|
||||
"""
|
||||
Represents a navigation structure where all patterns and paths have been
|
||||
resolved against the actual filesystem contents.
|
||||
|
||||
Attributes:
|
||||
home: Resolved relative path to the home page.
|
||||
groups: Mapping of group titles to lists of absolute or relative Path objects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home: str | None,
|
||||
groups: Dict[str, List[Path]],
|
||||
docs_root: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a ResolvedNav instance.
|
||||
|
||||
Args:
|
||||
home: The relative path to the project home page.
|
||||
groups: A mapping of group names to their resolved filesystem paths.
|
||||
docs_root: The root documentation directory.
|
||||
"""
|
||||
self.home = home
|
||||
self.groups = groups
|
||||
self._docs_root = docs_root
|
||||
|
||||
def all_files(self) -> Iterable[Path]:
|
||||
"""
|
||||
Get an iterable of all resolved files in the navigation structure.
|
||||
|
||||
Returns:
|
||||
An iterable of Path objects.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Create a ResolvedNav by processing a NavSpec against the filesystem.
|
||||
This expands globs and validates the existence of referenced files.
|
||||
|
||||
Args:
|
||||
spec: The navigation specification to resolve.
|
||||
docs_root: The root directory for documentation files.
|
||||
|
||||
Returns:
|
||||
A ResolvedNav instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If a pattern doesn't match any files or the docs_root doesn't exist.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
51
docforge/nav/resolver.pyi
Normal file
51
docforge/nav/resolver.pyi
Normal file
@@ -0,0 +1,51 @@
|
||||
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]]
|
||||
_docs_root: Optional[Path]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home: str | None,
|
||||
groups: Dict[str, List[Path]],
|
||||
docs_root: Path | None = ...,
|
||||
) -> 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
|
||||
"""
|
||||
...
|
||||
114
docforge/nav/spec.py
Normal file
114
docforge/nav/spec.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
This module defines the NavSpec class, which represents the user's intent for
|
||||
documentation navigation as defined in a YAML specification (usually
|
||||
docforge.nav.yml).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class NavSpec:
|
||||
"""
|
||||
Parsed representation of the docforge navigation specification file.
|
||||
|
||||
Attributes:
|
||||
home: Path to the home document (e.g., 'index.md').
|
||||
groups: Mapping of group titles to lists of path patterns/globs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
home: Optional[str],
|
||||
groups: Dict[str, List[str]],
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a NavSpec.
|
||||
|
||||
Args:
|
||||
home: The path to the home document.
|
||||
groups: A mapping of group names to lists of path patterns (globs).
|
||||
"""
|
||||
self.home = home
|
||||
self.groups = groups
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "NavSpec":
|
||||
"""
|
||||
Load a NavSpec from a YAML file.
|
||||
|
||||
Args:
|
||||
path: The filesystem path to the YAML file.
|
||||
|
||||
Returns:
|
||||
A NavSpec instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the path does not exist.
|
||||
ValueError: If the file content is not a valid NavSpec mapping.
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
Get all path patterns referenced in the specification.
|
||||
|
||||
Returns:
|
||||
A list of all patterns (home plus all groups).
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Utility function to load a NavSpec from a file.
|
||||
|
||||
Args:
|
||||
path: Path to the navigation specification file.
|
||||
|
||||
Returns:
|
||||
A loaded NavSpec instance.
|
||||
"""
|
||||
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: ...
|
||||
@@ -1,3 +1,22 @@
|
||||
"""
|
||||
# Renderers Layer
|
||||
|
||||
The `docforge.renderers` package handles the transformation of the internal
|
||||
documentation model into physical files formatted for specific documentation
|
||||
engines.
|
||||
|
||||
## Current Implementations
|
||||
|
||||
- **MkDocsRenderer**: Generates Markdown files utilizing the `mkdocstrings`
|
||||
syntax. It automatically handles package/module hierarchy and generates
|
||||
`index.md` files for packages.
|
||||
|
||||
## Extending
|
||||
|
||||
To add a new renderer, implement the `DocRenderer` protocol defined in
|
||||
`docforge.renderers.base`.
|
||||
"""
|
||||
|
||||
from .mkdocs import MkDocsRenderer
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module defines the base interfaces and configuration containers for
|
||||
doc-forge renderers. All renderer implementations should adhere to the
|
||||
DocRenderer protocol.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from docforge.model import Project
|
||||
|
||||
|
||||
class RendererConfig:
|
||||
"""Renderer configuration container."""
|
||||
"""
|
||||
Configuration container for documentation renderers.
|
||||
|
||||
Args:
|
||||
out_dir: The directory where documentation files should be written.
|
||||
project: The introspected project model to be rendered.
|
||||
"""
|
||||
|
||||
def __init__(self, out_dir: Path, project: Project) -> None:
|
||||
self.out_dir = out_dir
|
||||
self.project = project
|
||||
|
||||
|
||||
class DocRenderer(Protocol):
|
||||
"""
|
||||
Protocol defining the interface for documentation renderers.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def generate_sources(
|
||||
self,
|
||||
project: Project,
|
||||
out_dir: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Generate renderer-specific source files for the given project.
|
||||
|
||||
Args:
|
||||
project: The project model containing modules and objects.
|
||||
out_dir: Target directory for the generated files.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
This module implements the MkDocsRenderer, which generates Markdown source files
|
||||
compatible with the MkDocs 'material' theme and 'mkdocstrings' extension.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,52 +9,82 @@ from docforge.model import Project
|
||||
|
||||
|
||||
class MkDocsRenderer:
|
||||
"""MkDocs source generator using mkdocstrings."""
|
||||
"""
|
||||
Renderer that generates Markdown source files formatted for the MkDocs
|
||||
'mkdocstrings' plugin.
|
||||
"""
|
||||
|
||||
name = "mkdocs"
|
||||
|
||||
def generate_sources(self, project: Project, out_dir: Path) -> None:
|
||||
"""
|
||||
Generate Markdown files with mkdocstrings directives.
|
||||
Produce a set of Markdown files in the output directory based on the
|
||||
provided Project model.
|
||||
|
||||
Structure rules:
|
||||
- Each top-level package gets a directory
|
||||
- Modules become .md files
|
||||
- Packages (__init__) become index.md
|
||||
Args:
|
||||
project: The project model to render.
|
||||
out_dir: Target directory for documentation files.
|
||||
"""
|
||||
for module in project.get_all_modules():
|
||||
self._write_module(project, module, out_dir)
|
||||
modules = list(project.get_all_modules())
|
||||
paths = {m.path for m in modules}
|
||||
|
||||
# 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, module, packages: set[str], out_dir: Path) -> None:
|
||||
"""
|
||||
Write a single module's documentation file. Packages are written as
|
||||
'index.md' inside their respective directories.
|
||||
|
||||
def _write_module(self, project: Project, module, out_dir: Path) -> None:
|
||||
Args:
|
||||
module: The module to write.
|
||||
packages: A set of module paths that are identified as packages.
|
||||
out_dir: The base output directory.
|
||||
"""
|
||||
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")
|
||||
|
||||
def _render_markdown(self, title: str, module_path: str) -> str:
|
||||
"""
|
||||
Generate the Markdown content for a module file.
|
||||
|
||||
Args:
|
||||
title: The display title for the page.
|
||||
module_path: The dotted path of the module to document.
|
||||
|
||||
Returns:
|
||||
A string containing the Markdown source.
|
||||
"""
|
||||
return (
|
||||
f"# {title}\n\n"
|
||||
f"::: {module_path}\n"
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
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
|
||||
3
docs/docforge/cli/index.md
Normal file
3
docs/docforge/cli/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cli
|
||||
|
||||
::: docforge.cli
|
||||
3
docs/docforge/cli/main.md
Normal file
3
docs/docforge/cli/main.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Main
|
||||
|
||||
::: docforge.cli.main
|
||||
3
docs/docforge/cli/mkdocs.md
Normal file
3
docs/docforge/cli/mkdocs.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs
|
||||
|
||||
::: docforge.cli.mkdocs
|
||||
3
docs/docforge/index.md
Normal file
3
docs/docforge/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Docforge
|
||||
|
||||
::: docforge
|
||||
3
docs/docforge/loader/griffe_loader.md
Normal file
3
docs/docforge/loader/griffe_loader.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Griffe Loader
|
||||
|
||||
::: docforge.loader.griffe_loader
|
||||
3
docs/docforge/loader/index.md
Normal file
3
docs/docforge/loader/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Loader
|
||||
|
||||
::: docforge.loader
|
||||
3
docs/docforge/model/index.md
Normal file
3
docs/docforge/model/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Model
|
||||
|
||||
::: docforge.model
|
||||
3
docs/docforge/model/module.md
Normal file
3
docs/docforge/model/module.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Module
|
||||
|
||||
::: docforge.model.module
|
||||
3
docs/docforge/model/object.md
Normal file
3
docs/docforge/model/object.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Object
|
||||
|
||||
::: docforge.model.object
|
||||
3
docs/docforge/model/project.md
Normal file
3
docs/docforge/model/project.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Project
|
||||
|
||||
::: docforge.model.project
|
||||
3
docs/docforge/nav/index.md
Normal file
3
docs/docforge/nav/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Nav
|
||||
|
||||
::: docforge.nav
|
||||
3
docs/docforge/nav/mkdocs.md
Normal file
3
docs/docforge/nav/mkdocs.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs
|
||||
|
||||
::: docforge.nav.mkdocs
|
||||
3
docs/docforge/nav/resolver.md
Normal file
3
docs/docforge/nav/resolver.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Resolver
|
||||
|
||||
::: docforge.nav.resolver
|
||||
3
docs/docforge/nav/spec.md
Normal file
3
docs/docforge/nav/spec.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Spec
|
||||
|
||||
::: docforge.nav.spec
|
||||
3
docs/docforge/renderers/base.md
Normal file
3
docs/docforge/renderers/base.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Base
|
||||
|
||||
::: docforge.renderers.base
|
||||
3
docs/docforge/renderers/index.md
Normal file
3
docs/docforge/renderers/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Renderers
|
||||
|
||||
::: docforge.renderers
|
||||
3
docs/docforge/renderers/mkdocs.md
Normal file
3
docs/docforge/renderers/mkdocs.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs
|
||||
|
||||
::: docforge.renderers.mkdocs
|
||||
59
mkdocs.yml
Normal file
59
mkdocs.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
site_name: DocForge
|
||||
|
||||
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
|
||||
|
||||
nav:
|
||||
- Home: docforge/index.md
|
||||
- Loader:
|
||||
- docforge/loader/index.md
|
||||
- docforge/loader/griffe_loader.md
|
||||
- Model:
|
||||
- docforge/model/index.md
|
||||
- docforge/model/module.md
|
||||
- docforge/model/object.md
|
||||
- docforge/model/project.md
|
||||
- Navigation:
|
||||
- docforge/nav/index.md
|
||||
- docforge/nav/spec.md
|
||||
- docforge/nav/resolver.md
|
||||
- docforge/nav/mkdocs.md
|
||||
- Renderers:
|
||||
- docforge/renderers/index.md
|
||||
- docforge/renderers/base.md
|
||||
- docforge/renderers/mkdocs.md
|
||||
- CLI:
|
||||
- docforge/cli/index.md
|
||||
- docforge/cli/main.md
|
||||
- docforge/cli/mkdocs.md
|
||||
@@ -1,38 +1,56 @@
|
||||
[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",
|
||||
"mkdocstrings[python]>=0.20.0",
|
||||
"mkdocs==1.6.1",
|
||||
"mkdocs-material==9.6.23",
|
||||
|
||||
"mkdocstrings==0.25.2",
|
||||
"mkdocstrings-python==1.10.8",
|
||||
"mkdocs-autorefs==0.5.0",
|
||||
|
||||
"pymdown-extensions==10.16.1",
|
||||
"neoteroi-mkdocs==1.1.3",
|
||||
]
|
||||
sphinx = [
|
||||
"sphinx>=5.0.0",
|
||||
@@ -49,38 +67,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
|
||||
|
||||
@@ -3,30 +3,33 @@ from pathlib import Path
|
||||
from docforge.cli.main import cli
|
||||
|
||||
|
||||
def test_generate_command(cli_runner, temp_package, tmp_path: Path):
|
||||
(temp_package / "mod.py").write_text(
|
||||
'''
|
||||
def f(): ...
|
||||
'''
|
||||
)
|
||||
def test_generate_command(cli_runner):
|
||||
with cli_runner.isolated_filesystem():
|
||||
cwd = Path.cwd()
|
||||
|
||||
docs_dir = tmp_path / "docs"
|
||||
# Create package structure
|
||||
pkg = cwd / "testpkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
(pkg / "mod.py").write_text("def f(): ...\n")
|
||||
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"--modules",
|
||||
"testpkg.mod",
|
||||
"--docs-dir",
|
||||
str(docs_dir),
|
||||
],
|
||||
)
|
||||
docs_dir = cwd / "docs"
|
||||
|
||||
assert result.exit_code == 0
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"--module",
|
||||
"testpkg",
|
||||
"--docs-dir",
|
||||
str(docs_dir),
|
||||
],
|
||||
)
|
||||
|
||||
md = docs_dir / "testpkg" / "mod.md"
|
||||
assert md.exists()
|
||||
assert result.exit_code == 0
|
||||
|
||||
content = md.read_text()
|
||||
assert "::: testpkg.mod" in content
|
||||
md = docs_dir / "testpkg" / "mod.md"
|
||||
assert md.exists()
|
||||
|
||||
content = md.read_text()
|
||||
assert "::: testpkg.mod" in content
|
||||
|
||||
123
tests/cli/test_mkdocs.py
Normal file
123
tests/cli/test_mkdocs.py
Normal file
@@ -0,0 +1,123 @@
|
||||
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",
|
||||
"--site-name",
|
||||
"DocForge",
|
||||
"--docs-dir",
|
||||
str(docs),
|
||||
"--nav",
|
||||
str(nav_file),
|
||||
"--out",
|
||||
str(out_file),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert out_file.exists()
|
||||
|
||||
|
||||
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",
|
||||
"--site-name",
|
||||
"Override Site",
|
||||
"--docs-dir",
|
||||
str(docs),
|
||||
"--nav",
|
||||
str(nav_file),
|
||||
"--template",
|
||||
str(template),
|
||||
"--out",
|
||||
str(out_file),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert out_file.exists()
|
||||
|
||||
|
||||
def test_mkdocs_missing_nav_fails(tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"mkdocs",
|
||||
"--site-name",
|
||||
"DocForge",
|
||||
"--docs-dir",
|
||||
str(docs),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Nav spec not found" in result.output
|
||||
@@ -1,12 +1,21 @@
|
||||
import pytest
|
||||
from docforge import GriffeLoader
|
||||
|
||||
|
||||
def test_import_failure_does_not_crash():
|
||||
def test_load_project_raises_on_missing_module_by_default():
|
||||
loader = GriffeLoader()
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
loader.load_project(
|
||||
["nonexistent.module", "sys"]
|
||||
)
|
||||
|
||||
def test_load_project_skips_missing_modules_when_enabled():
|
||||
loader = GriffeLoader()
|
||||
|
||||
project = loader.load_project(
|
||||
["nonexistent.module", "sys"]
|
||||
["nonexistent.module", "sys"],
|
||||
skip_import_errors=True,
|
||||
)
|
||||
|
||||
# sys should still load
|
||||
assert "sys" in project.modules
|
||||
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