diff --git a/docforge/__init__.py b/docforge/__init__.py index fcd0c8b..c99bf33 100644 --- a/docforge/__init__.py +++ b/docforge/__init__.py @@ -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 diff --git a/docforge/cli/__init__.py b/docforge/cli/__init__.py index aef0a0c..7ced811 100644 --- a/docforge/cli/__init__.py +++ b/docforge/cli/__init__.py @@ -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__ = [ diff --git a/docforge/cli/main.py b/docforge/cli/main.py index d72363d..b20b6c6 100644 --- a/docforge/cli/main.py +++ b/docforge/cli/main.py @@ -1,3 +1,8 @@ +""" +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 @@ -10,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 @@ -35,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) @@ -49,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 + "│ ") @@ -78,7 +95,14 @@ def generate( 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( module, @@ -105,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}") @@ -128,7 +157,12 @@ 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}") @@ -147,6 +181,9 @@ def serve(mkdocs_yml: Path) -> None: # --------------------------------------------------------------------- def main() -> None: + """ + CLI Entry point. + """ cli() diff --git a/docforge/cli/mkdocs.py b/docforge/cli/mkdocs.py index 88529c1..df9210f 100644 --- a/docforge/cli/mkdocs.py +++ b/docforge/cli/mkdocs.py @@ -1,3 +1,8 @@ +""" +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 @@ -10,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") @@ -59,7 +74,17 @@ def mkdocs_cmd( 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") diff --git a/docforge/loader/__init__.py b/docforge/loader/__init__.py index 10ac315..6512b4f 100644 --- a/docforge/loader/__init__.py +++ b/docforge/loader/__init__.py @@ -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__ = [ diff --git a/docforge/loader/griffe_loader.py b/docforge/loader/griffe_loader.py index 9873458..bbf2b18 100644 --- a/docforge/loader/griffe_loader.py +++ b/docforge/loader/griffe_loader.py @@ -1,3 +1,8 @@ +""" +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 @@ -23,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: @@ -51,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,6 +82,17 @@ class GriffeLoader: 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") @@ -87,6 +115,15 @@ class GriffeLoader: return project def load_module(self, path: str) -> Module: + """ + 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] diff --git a/docforge/model/__init__.py b/docforge/model/__init__.py index aa81bf9..19b9ee8 100644 --- a/docforge/model/__init__.py +++ b/docforge/model/__init__.py @@ -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 diff --git a/docforge/model/module.py b/docforge/model/module.py index c7908f5..e741a55 100644 --- a/docforge/model/module.py +++ b/docforge/model/module.py @@ -1,25 +1,66 @@ +""" +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 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() diff --git a/docforge/model/object.py b/docforge/model/object.py index 8edb79c..e1b625d 100644 --- a/docforge/model/object.py +++ b/docforge/model/object.py @@ -1,8 +1,24 @@ +""" +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, @@ -12,6 +28,16 @@ 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 @@ -20,10 +46,31 @@ class DocObject: self.members: Dict[str, 'DocObject'] = {} 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': + """ + 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']: + """ + Get all members of this object. + + Returns: + An iterable of child DocObject instances. + """ return self.members.values() diff --git a/docforge/model/project.py b/docforge/model/project.py index 46fdeeb..c444958 100644 --- a/docforge/model/project.py +++ b/docforge/model/project.py @@ -1,23 +1,67 @@ +""" +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 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()) diff --git a/docforge/nav/__init__.py b/docforge/nav/__init__.py index 2db95eb..7a2aa94 100644 --- a/docforge/nav/__init__.py +++ b/docforge/nav/__init__.py @@ -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 diff --git a/docforge/nav/mkdocs.py b/docforge/nav/mkdocs.py index c1d516c..d304cf8 100644 --- a/docforge/nav/mkdocs.py +++ b/docforge/nav/mkdocs.py @@ -1,3 +1,9 @@ +""" +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 @@ -5,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) @@ -27,7 +45,16 @@ class MkDocsNavEmitter: 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. """ if docs_root and path.is_absolute(): try: diff --git a/docforge/nav/resolver.py b/docforge/nav/resolver.py index f62ee46..3333ab6 100644 --- a/docforge/nav/resolver.py +++ b/docforge/nav/resolver.py @@ -1,3 +1,9 @@ +""" +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 @@ -7,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") @@ -31,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) diff --git a/docforge/nav/spec.py b/docforge/nav/spec.py index 226aeae..c7cc784 100644 --- a/docforge/nav/spec.py +++ b/docforge/nav/spec.py @@ -1,3 +1,9 @@ +""" +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 @@ -5,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) @@ -45,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) @@ -54,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) diff --git a/docforge/renderers/__init__.py b/docforge/renderers/__init__.py index 732f508..fb9792a 100644 --- a/docforge/renderers/__init__.py +++ b/docforge/renderers/__init__.py @@ -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__ = [ diff --git a/docforge/renderers/base.py b/docforge/renderers/base.py index d3e7d30..b630a0c 100644 --- a/docforge/renderers/base.py +++ b/docforge/renderers/base.py @@ -1,11 +1,46 @@ +""" +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. + """ + ... diff --git a/docforge/renderers/mkdocs.py b/docforge/renderers/mkdocs.py index 1c51c75..9d9f5e3 100644 --- a/docforge/renderers/mkdocs.py +++ b/docforge/renderers/mkdocs.py @@ -1,14 +1,30 @@ +""" +This module implements the MkDocsRenderer, which generates Markdown source files +compatible with the MkDocs 'material' theme and 'mkdocstrings' extension. +""" + from pathlib import Path 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} @@ -25,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: @@ -50,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"