Compare commits

...

14 Commits

Author SHA1 Message Date
b6e5114532 added docs strings
All checks were successful
continuous-integration/drone/tag Build is passing
2026-01-21 01:00:12 +05:30
81e8a8cd49 pyi matching 2026-01-21 00:43:27 +05:30
be8f23c8ab added docs folder 2026-01-21 00:35:56 +05:30
9392d2c999 added mkdocs.yml 2026-01-21 00:35:33 +05:30
9d0b6e78d1 fixed test cases 2026-01-21 00:34:09 +05:30
4fa3bc0533 feat(cli,mkdocs): require site_name, fix nav paths, and echo serve URL
- Require `--site-name` when generating mkdocs.yml to ensure valid configs
- Inject site_name explicitly into generated mkdocs.yml
- Echo MkDocs serve URL (http://127.0.0.1:8000) before starting server
- Fix MkDocs nav emission to correctly resolve docs-relative paths
- Align MkDocs-related optional dependencies with a compatible, pinned set

These changes make MkDocs generation valid by default, improve UX when serving,
and prevent nav path and plugin compatibility issues.
2026-01-21 00:32:29 +05:30
46b7cc52e1 added docforge nav file 2026-01-21 00:31:41 +05:30
c8ecc6a476 removed mkdocs.py as ignored file 2026-01-21 00:31:17 +05:30
5c8d9dcc9c generate test command fixed 2026-01-20 23:59:51 +05:30
b497c5d2e9 fixed generate command, removed hardcoding 2026-01-20 23:57:57 +05:30
0061dbe2eb fixed the skip_import_error option 2026-01-20 23:50:24 +05:30
6f9776dff2 broken griffe_loader.py if we want to skip import errors. wip flag for the same 2026-01-20 23:42:48 +05:30
6c9fb433cb fixed Type 2026-01-20 23:35:57 +05:30
6b334fd181 removed future anotations 2026-01-20 23:35:41 +05:30
47 changed files with 808 additions and 130 deletions

1
.gitignore vendored
View File

@@ -57,7 +57,6 @@ docs/build/
# =========================
# MkDocs / Sphinx output
# =========================
mkdocs.yml
site/
.build/
_doctrees/

23
docforge.nav.yml Normal file
View 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

View File

@@ -1,9 +1,54 @@
"""
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, discover_module_paths

View File

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

View File

@@ -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__ = [

View File

@@ -1,4 +1,7 @@
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
@@ -12,7 +15,10 @@ 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
@@ -37,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)
@@ -51,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 + "")
@@ -62,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",
)
@@ -77,15 +91,21 @@ 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()
discovered_paths = discover_module_paths(
"docforge",
Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge")
module,
)
project = loader.load_project(
discovered_paths,
@@ -109,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}")
@@ -132,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))
@@ -145,6 +181,9 @@ def serve(mkdocs_yml: Path) -> None:
# ---------------------------------------------------------------------
def main() -> None:
"""
CLI Entry point.
"""
cli()

View File

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

View File

@@ -1,4 +1,7 @@
from __future__ import annotations
"""
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
@@ -12,6 +15,16 @@ 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")
@@ -27,6 +40,11 @@ def _load_template(template: Path | None) -> dict:
@click.command("mkdocs")
@click.option(
"--site-name",
required=True,
help="MkDocs site_name (required)",
)
@click.option(
"--docs-dir",
type=click.Path(path_type=Path),
@@ -54,8 +72,19 @@ def mkdocs_cmd(
nav_file: Path,
template: Path | None,
out: Path,
site_name: str,
) -> None:
"""Generate mkdocs.yml from nav spec and template."""
"""
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")
@@ -72,6 +101,9 @@ def mkdocs_cmd(
# Load template (user or built-in)
data = _load_template(template)
# Inject site_name
data["site_name"] = site_name
# Inject nav
data["nav"] = nav_block

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
@@ -11,6 +9,11 @@ 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),
@@ -37,5 +40,6 @@ def mkdocs_cmd(
nav_file: Path,
template: Optional[Path],
out: Path,
site_name: str,
) -> None:
...

View File

@@ -1,3 +1,19 @@
"""
# 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__ = [

View File

@@ -1,4 +1,7 @@
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
@@ -25,9 +28,16 @@ def discover_module_paths(
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
- 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:
@@ -53,9 +63,14 @@ def discover_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(),
@@ -65,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")
@@ -77,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)
@@ -106,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
@@ -125,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

View File

@@ -23,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."""

View File

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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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())

View File

@@ -1,3 +1,19 @@
"""
# 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

View File

@@ -1,4 +1,8 @@
from __future__ import annotations
"""
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
@@ -7,9 +11,21 @@ from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""Emit MkDocs-compatible nav structures."""
"""
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)
@@ -21,14 +37,36 @@ class MkDocsNavEmitter:
entries: List[str] = []
for p in paths:
# Convert filesystem path back to docs-relative path
entries.append(self._to_relative(p))
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) -> str:
def _to_relative(self, path: Path, docs_root: Path | None) -> str:
"""
Convert a filesystem path to a docs-relative path.
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.
"""
# Normalize to POSIX-style for MkDocs
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]

View File

@@ -27,7 +27,7 @@ class MkDocsNavEmitter:
"""
...
def _to_relative(self, path: Path) -> str:
def _to_relative(self, path: Path, docs_root: Path | None) -> str:
"""
Convert a filesystem path to a docs-relative path.
"""

View File

@@ -1,4 +1,8 @@
from __future__ import annotations
"""
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
@@ -9,17 +13,40 @@ 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")
@@ -33,6 +60,20 @@ 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)

View File

@@ -15,15 +15,14 @@ class ResolvedNav:
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,
) -> None:
self._docs_root = None
...
docs_root: Path | None = ...,
) -> None: ...
def all_files(self) -> Iterable[Path]:
"""

View File

@@ -1,4 +1,8 @@
from __future__ import annotations
"""
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
@@ -7,18 +11,44 @@ import yaml
class NavSpec:
"""Parsed representation of docforge.nav.yml."""
"""
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)
@@ -47,6 +77,12 @@ class NavSpec:
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)
@@ -56,6 +92,15 @@ class NavSpec:
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)

View File

@@ -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__ = [

View File

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

View File

@@ -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,11 +9,22 @@ 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:
"""
Produce a set of Markdown files in the output directory based on the
provided Project model.
Args:
project: The project model to render.
out_dir: Target directory for documentation files.
"""
modules = list(project.get_all_modules())
paths = {m.path for m in modules}
@@ -27,6 +41,15 @@ class MkDocsRenderer:
# 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.
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(".")
if module.path in packages:
@@ -52,6 +75,16 @@ class MkDocsRenderer:
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"

View File

@@ -0,0 +1,3 @@
# Cli
::: docforge.cli

View File

@@ -0,0 +1,3 @@
# Main
::: docforge.cli.main

View File

@@ -0,0 +1,3 @@
# Mkdocs
::: docforge.cli.mkdocs

3
docs/docforge/index.md Normal file
View File

@@ -0,0 +1,3 @@
# Docforge
::: docforge

View File

@@ -0,0 +1,3 @@
# Griffe Loader
::: docforge.loader.griffe_loader

View File

@@ -0,0 +1,3 @@
# Loader
::: docforge.loader

View File

@@ -0,0 +1,3 @@
# Model
::: docforge.model

View File

@@ -0,0 +1,3 @@
# Module
::: docforge.model.module

View File

@@ -0,0 +1,3 @@
# Object
::: docforge.model.object

View File

@@ -0,0 +1,3 @@
# Project
::: docforge.model.project

View File

@@ -0,0 +1,3 @@
# Nav
::: docforge.nav

View File

@@ -0,0 +1,3 @@
# Mkdocs
::: docforge.nav.mkdocs

View File

@@ -0,0 +1,3 @@
# Resolver
::: docforge.nav.resolver

View File

@@ -0,0 +1,3 @@
# Spec
::: docforge.nav.spec

View File

@@ -0,0 +1,3 @@
# Base
::: docforge.renderers.base

View File

@@ -0,0 +1,3 @@
# Renderers
::: docforge.renderers

View File

@@ -0,0 +1,3 @@
# Mkdocs
::: docforge.renderers.mkdocs

59
mkdocs.yml Normal file
View 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

View File

@@ -42,8 +42,15 @@ 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",

View File

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

View File

@@ -46,6 +46,8 @@ def test_mkdocs_uses_builtin_template(tmp_path: Path) -> None:
cli,
[
"mkdocs",
"--site-name",
"DocForge",
"--docs-dir",
str(docs),
"--nav",
@@ -58,24 +60,6 @@ def test_mkdocs_uses_builtin_template(tmp_path: Path) -> None:
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"
@@ -102,6 +86,8 @@ theme:
cli,
[
"mkdocs",
"--site-name",
"Override Site",
"--docs-dir",
str(docs),
"--nav",
@@ -114,12 +100,7 @@ theme:
)
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
assert out_file.exists()
def test_mkdocs_missing_nav_fails(tmp_path: Path) -> None:
@@ -131,6 +112,8 @@ def test_mkdocs_missing_nav_fails(tmp_path: Path) -> None:
cli,
[
"mkdocs",
"--site-name",
"DocForge",
"--docs-dir",
str(docs),
],

View File

@@ -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
assert "sys" in project.modules