2 Commits

62 changed files with 434 additions and 273 deletions

View File

@@ -32,6 +32,26 @@ steps:
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION" echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
fi fi
- name: quality-gate
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 pip build
- |
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]"
- echo "🛡️ Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy
- python -m pytest
- name: build-package - name: build-package
image: python:3.13-slim image: python:3.13-slim
commands: commands:

5
.gitignore vendored
View File

@@ -87,3 +87,8 @@ Thumbs.db
# ========================= # =========================
.env .env
.env.* .env.*
# =========================
# Testing artifacts
# =========================
coverage.xml

21
CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `py.typed` marker for PEP 561 type information.
- `.drone.yml` CI with a quality-gate step (black, ruff, mypy, pytest).
- MIT `LICENSE`.
### Changed
- Standardized `pyproject.toml` (canonical packaging, lint tool config, extras).
- Pinned the `mcp` extra to `mcp>=1.0.0,<2.0.0` to restore compatibility with
the current MCP server API used by doc-forge.
### Fixed
- Stub fixes for typed API surfaces.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aetoskia Platform
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
from .loaders import GriffeLoader, discover_module_paths
from .renderers import MkDocsRenderer, MCPRenderer
from .cli import main
from . import models from . import models
from .cli import main
from .loaders import GriffeLoader, discover_module_paths
from .renderers import MCPRenderer, MkDocsRenderer
__all__ = [ __all__ = [
"GriffeLoader", "GriffeLoader",

View File

@@ -37,6 +37,4 @@ Example:
from .main import main from .main import main
__all__ = [ __all__ = ["main"]
"main"
]

View File

@@ -1,5 +1,3 @@
from .main import main from .main import main
__all__ = [ __all__ = ["main"]
"main"
]

View File

@@ -6,12 +6,12 @@ Command definitions for the doc-forge CLI.
Provides the CLI structure using Click, including build, serve, and tree commands. Provides the CLI structure using Click, including build, serve, and tree commands.
""" """
import click
from pathlib import Path from pathlib import Path
from typing import Sequence, Optional
import click
from docforge.cli import mcp_utils, mkdocs_utils
from docforge.loaders import GriffeLoader from docforge.loaders import GriffeLoader
from docforge.cli import mkdocs_utils
from docforge.cli import mcp_utils
@click.group() @click.group()
@@ -28,26 +28,52 @@ def cli() -> None:
@cli.command() @cli.command()
@click.option("--mcp", is_flag=True, help="Build MCP resources") @click.option("--mcp", is_flag=True, help="Build MCP resources")
@click.option("--mkdocs", is_flag=True, help="Build MkDocs site") @click.option("--mkdocs", is_flag=True, help="Build MkDocs site")
@click.option("--module-is-source", is_flag=True, help="Module is source folder and to be treated as root folder") @click.option(
"--module-is-source",
is_flag=True,
help="Module is source folder and to be treated as root folder",
)
@click.option("--module", help="Python module to document") @click.option("--module", help="Python module to document")
@click.option("--project-name", help="Project name override") @click.option("--project-name", help="Project name override")
@click.option("--site-name", help="MkDocs site name") @click.option("--site-name", help="MkDocs site name")
@click.option("--docs-dir", type=click.Path(path_type=Path), default=Path("docs"), help="Directory for MD sources") @click.option(
@click.option("--nav", "nav_file", type=click.Path(path_type=Path), default=Path("docforge.nav.yml"), "--docs-dir",
help="Nav spec path") type=click.Path(path_type=Path),
@click.option("--template", type=click.Path(path_type=Path), help="MkDocs template path") default=Path("docs"),
@click.option("--mkdocs-yml", type=click.Path(path_type=Path), default=Path("mkdocs.yml"), help="Output config path") help="Directory for MD sources",
@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("mcp_docs"), help="MCP output directory") )
@click.option(
"--nav",
"nav_file",
type=click.Path(path_type=Path),
default=Path("docforge.nav.yml"),
help="Nav spec path",
)
@click.option(
"--template", type=click.Path(path_type=Path), help="MkDocs template path"
)
@click.option(
"--mkdocs-yml",
type=click.Path(path_type=Path),
default=Path("mkdocs.yml"),
help="Output config path",
)
@click.option(
"--out-dir",
type=click.Path(path_type=Path),
default=Path("mcp_docs"),
help="MCP output directory",
)
def build( def build(
mcp: bool, mcp: bool,
mkdocs: bool, mkdocs: bool,
module_is_source: bool, module_is_source: bool,
module: Optional[str], module: str | None,
project_name: Optional[str], project_name: str | None,
site_name: Optional[str], site_name: str | None,
docs_dir: Path, docs_dir: Path,
nav_file: Path, nav_file: Path,
template: Optional[Path], template: Path | None,
mkdocs_yml: Path, mkdocs_yml: Path,
out_dir: Path, out_dir: Path,
) -> None: ) -> None:
@@ -121,7 +147,9 @@ def build(
) )
click.echo(f"Generating MkDocs config {mkdocs_yml}...") click.echo(f"Generating MkDocs config {mkdocs_yml}...")
mkdocs_utils.generate_config(docs_dir, nav_file, template, mkdocs_yml, site_name) mkdocs_utils.generate_config(
docs_dir, nav_file, template, mkdocs_yml, site_name
)
click.echo("Running MkDocs build...") click.echo("Running MkDocs build...")
mkdocs_utils.build(mkdocs_yml) mkdocs_utils.build(mkdocs_yml)
@@ -140,12 +168,22 @@ def build(
@click.option("--mcp", is_flag=True, help="Serve MCP documentation") @click.option("--mcp", is_flag=True, help="Serve MCP documentation")
@click.option("--mkdocs", is_flag=True, help="Serve MkDocs site") @click.option("--mkdocs", is_flag=True, help="Serve MkDocs site")
@click.option("--module", help="Python module to serve") @click.option("--module", help="Python module to serve")
@click.option("--mkdocs-yml", type=click.Path(path_type=Path), default=Path("mkdocs.yml"), help="MkDocs config path") @click.option(
@click.option("--out-dir", type=click.Path(path_type=Path), default=Path("mcp_docs"), help="MCP root directory") "--mkdocs-yml",
type=click.Path(path_type=Path),
default=Path("mkdocs.yml"),
help="MkDocs config path",
)
@click.option(
"--out-dir",
type=click.Path(path_type=Path),
default=Path("mcp_docs"),
help="MCP root directory",
)
def serve( def serve(
mcp: bool, mcp: bool,
mkdocs: bool, mkdocs: bool,
module: Optional[str], module: str | None,
mkdocs_yml: Path, mkdocs_yml: Path,
out_dir: Path, out_dir: Path,
) -> None: ) -> None:
@@ -202,7 +240,7 @@ def serve(
) )
def tree( def tree(
module: str, module: str,
project_name: Optional[str], project_name: str | None,
) -> None: ) -> None:
""" """
Display the documentation object tree for a module. Display the documentation object tree for a module.

View File

@@ -1,6 +1,7 @@
from click.core import Group
from pathlib import Path from pathlib import Path
from typing import Sequence, Optional, Any from typing import Any
from click.core import Group
cli: Group cli: Group
@@ -8,27 +9,24 @@ def build(
mcp: bool, mcp: bool,
mkdocs: bool, mkdocs: bool,
module_is_source: bool, module_is_source: bool,
module: Optional[str], module: str | None,
project_name: Optional[str], project_name: str | None,
site_name: Optional[str], site_name: str | None,
docs_dir: Path, docs_dir: Path,
nav_file: Path, nav_file: Path,
template: Optional[Path], template: Path | None,
mkdocs_yml: Path, mkdocs_yml: Path,
out_dir: Path, out_dir: Path,
) -> None: ... ) -> None: ...
def serve( def serve(
mcp: bool, mcp: bool,
mkdocs: bool, mkdocs: bool,
module: Optional[str], module: str | None,
mkdocs_yml: Path, mkdocs_yml: Path,
out_dir: Path, out_dir: Path,
) -> None: ... ) -> None: ...
def tree( def tree(
module: str, module: str,
project_name: Optional[str], project_name: str | None,
) -> None: ... ) -> None: ...
def _print_object(obj: Any, indent: str) -> None: ... def _print_object(obj: Any, indent: str) -> None: ...

View File

@@ -5,7 +5,9 @@ Utilities for working with MCP in the doc-forge CLI.
""" """
from pathlib import Path from pathlib import Path
import click import click
from docforge.loaders import GriffeLoader, discover_module_paths from docforge.loaders import GriffeLoader, discover_module_paths
from docforge.renderers import MCPRenderer from docforge.renderers import MCPRenderer
from docforge.servers import MCPServer from docforge.servers import MCPServer

View File

@@ -1,4 +1,6 @@
from pathlib import Path from pathlib import Path
def generate_resources(module: str, project_name: str | None, out_dir: Path) -> None: ... def generate_resources(
module: str, project_name: str | None, out_dir: Path
) -> None: ...
def serve(module: str, mcp_root: Path) -> None: ... def serve(module: str, mcp_root: Path) -> None: ...

View File

@@ -4,13 +4,15 @@
Utilities for working with MkDocs in the doc-forge CLI. Utilities for working with MkDocs in the doc-forge CLI.
""" """
from pathlib import Path
from importlib import resources from importlib import resources
from pathlib import Path
import click import click
import yaml import yaml
from docforge.loaders import GriffeLoader, discover_module_paths from docforge.loaders import GriffeLoader, discover_module_paths
from docforge.nav import MkDocsNavEmitter, load_nav_spec, resolve_nav
from docforge.renderers import MkDocsRenderer from docforge.renderers import MkDocsRenderer
from docforge.nav import load_nav_spec, resolve_nav, MkDocsNavEmitter
def generate_sources( def generate_sources(
@@ -138,8 +140,8 @@ def build(mkdocs_yml: Path) -> None:
if not mkdocs_yml.exists(): if not mkdocs_yml.exists():
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}") raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
from mkdocs.config import load_config
from mkdocs.commands.build import build as mkdocs_build from mkdocs.commands.build import build as mkdocs_build
from mkdocs.config import load_config
mkdocs_build(load_config(str(mkdocs_yml))) mkdocs_build(load_config(str(mkdocs_yml)))
@@ -163,4 +165,5 @@ def serve(mkdocs_yml: Path) -> None:
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}") raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
from mkdocs.commands.serve import serve as mkdocs_serve from mkdocs.commands.serve import serve as mkdocs_serve
mkdocs_serve(config_file=str(mkdocs_yml)) mkdocs_serve(config_file=str(mkdocs_yml))

View File

@@ -6,6 +6,8 @@ def generate_sources(
project_name: str | None = None, project_name: str | None = None,
module_is_source: bool | None = None, module_is_source: bool | None = None,
) -> None: ... ) -> None: ...
def generate_config(docs_dir: Path, nav_file: Path, template: Path | None, out: Path, site_name: str) -> None: ... def generate_config(
docs_dir: Path, nav_file: Path, template: Path | None, out: Path, site_name: str
) -> None: ...
def build(mkdocs_yml: Path) -> None: ... def build(mkdocs_yml: Path) -> None: ...
def serve(mkdocs_yml: Path) -> None: ... def serve(mkdocs_yml: Path) -> None: ...

View File

@@ -10,17 +10,18 @@ into doc-forge documentation models.
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional
from griffe import ( from griffe import (
GriffeLoader as _GriffeLoader,
ModulesCollection,
LinesCollection,
Object,
AliasResolutionError, AliasResolutionError,
LinesCollection,
ModulesCollection,
Object,
)
from griffe import (
GriffeLoader as _GriffeLoader,
) )
from docforge.models import Module, Project, DocObject from docforge.models import DocObject, Module, Project
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
def discover_module_paths( def discover_module_paths(
module_name: str, module_name: str,
project_root: Path | None = None, project_root: Path | None = None,
) -> List[str]: ) -> list[str]:
""" """
Discover Python modules within a package directory. Discover Python modules within a package directory.
@@ -65,7 +66,7 @@ def discover_module_paths(
if not pkg_dir.exists(): if not pkg_dir.exists():
raise FileNotFoundError(f"Package not found: {pkg_dir}") raise FileNotFoundError(f"Package not found: {pkg_dir}")
module_paths: List[str] = [] module_paths: list[str] = []
for path in pkg_dir.rglob("*.py"): for path in pkg_dir.rglob("*.py"):
if path.name == "__init__.py": if path.name == "__init__.py":
@@ -102,10 +103,10 @@ class GriffeLoader:
) )
def load_project( def load_project(
self, self,
module_paths: List[str], module_paths: list[str],
project_name: Optional[str] = None, project_name: str | None = None,
skip_import_errors: bool = None, skip_import_errors: bool = None,
) -> Project: ) -> Project:
""" """
Load multiple modules and assemble them into a Project model. Load multiple modules and assemble them into a Project model.
@@ -250,7 +251,7 @@ class GriffeLoader:
# Safe extractors # Safe extractors
# ------------------------- # -------------------------
def _safe_docstring(self, obj: Object) -> Optional[str]: def _safe_docstring(self, obj: Object) -> str | None:
""" """
Safely extract a docstring from a Griffe object. Safely extract a docstring from a Griffe object.
@@ -267,7 +268,7 @@ class GriffeLoader:
except AliasResolutionError: except AliasResolutionError:
return None return None
def _safe_signature(self, obj: Object) -> Optional[str]: def _safe_signature(self, obj: Object) -> str | None:
""" """
Safely extract the signature of a Griffe object. Safely extract the signature of a Griffe object.

View File

@@ -1,15 +1,11 @@
from typing import List, Optional
from pathlib import Path from pathlib import Path
from docforge.models import Module, Project from docforge.models import Module, Project
def discover_module_paths( def discover_module_paths(
module_name: str, module_name: str,
project_root: Path | None = None, project_root: Path | None = None,
) -> List[str]: ) -> list[str]: ...
...
class GriffeLoader: class GriffeLoader:
"""Griffe-based introspection loaders. """Griffe-based introspection loaders.
@@ -18,11 +14,10 @@ class GriffeLoader:
""" """
def __init__(self) -> None: ... def __init__(self) -> None: ...
def load_project( def load_project(
self, self,
module_paths: List[str], module_paths: list[str],
project_name: Optional[str] = ..., project_name: str | None = ...,
skip_import_errors: bool = ..., skip_import_errors: bool = ...,
) -> Project: ) -> Project:
"""Load a documentation project from Python modules.""" """Load a documentation project from Python modules."""

View File

@@ -1,6 +1,6 @@
from .project import Project
from .module import Module from .module import Module
from .object import DocObject from .object import DocObject
from .project import Project
__all__ = [ __all__ = [
"Project", "Project",

View File

@@ -9,7 +9,7 @@ model. A `Module` acts as a container for top-level documented objects
introspection. introspection.
""" """
from typing import Dict, Iterable, Optional from collections.abc import Iterable
from docforge.models.object import DocObject from docforge.models.object import DocObject
@@ -36,7 +36,7 @@ class Module:
def __init__( def __init__(
self, self,
path: str, path: str,
docstring: Optional[str] = None, docstring: str | None = None,
) -> None: ) -> None:
""" """
Initialize a Module instance. Initialize a Module instance.
@@ -50,7 +50,7 @@ class Module:
""" """
self.path = path self.path = path
self.docstring = docstring self.docstring = docstring
self.members: Dict[str, DocObject] = {} self.members: dict[str, DocObject] = {}
def add_object(self, obj: DocObject) -> None: def add_object(self, obj: DocObject) -> None:
""" """

View File

@@ -1,23 +1,19 @@
from typing import Dict, Iterable, Optional from collections.abc import Iterable
from docforge.models.object import DocObject from docforge.models.object import DocObject
class Module: class Module:
"""Represents a documented Python module.""" """Represents a documented Python module."""
path: str path: str
docstring: Optional[str] docstring: str | None
members: Dict[str, DocObject] members: dict[str, DocObject]
def __init__( def __init__(
self, self,
path: str, path: str,
docstring: Optional[str] = ..., docstring: str | None = ...,
) -> None: ... ) -> None: ...
def add_object(self, obj: DocObject) -> None: ... def add_object(self, obj: DocObject) -> None: ...
def get_object(self, name: str) -> DocObject: ... def get_object(self, name: str) -> DocObject: ...
def get_all_objects(self) -> Iterable[DocObject]: ... def get_all_objects(self) -> Iterable[DocObject]: ...

View File

@@ -9,7 +9,7 @@ entity such as a class, function, method, or attribute, and may contain nested
members that form a hierarchical documentation structure. members that form a hierarchical documentation structure.
""" """
from typing import Dict, Iterable, Optional from collections.abc import Iterable
class DocObject: class DocObject:
@@ -45,8 +45,8 @@ class DocObject:
name: str, name: str,
kind: str, kind: str,
path: str, path: str,
signature: Optional[str] = None, signature: str | None = None,
docstring: Optional[str] = None, docstring: str | None = None,
) -> None: ) -> None:
""" """
Initialize a DocObject instance. Initialize a DocObject instance.
@@ -72,7 +72,7 @@ class DocObject:
self.path = path self.path = path
self.signature = signature self.signature = signature
self.docstring = docstring 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:
""" """

View File

@@ -1,5 +1,4 @@
from typing import Dict, Iterable, Optional from collections.abc import Iterable
class DocObject: class DocObject:
"""Represents a documented Python object.""" """Represents a documented Python object."""
@@ -7,21 +6,18 @@ class DocObject:
name: str name: str
kind: str kind: str
path: str path: str
signature: Optional[str] signature: str | None
docstring: Optional[str] docstring: str | None
members: Dict[str, "DocObject"] members: dict[str, DocObject]
def __init__( def __init__(
self, self,
name: str, name: str,
kind: str, kind: str,
path: str, path: str,
signature: Optional[str] = ..., signature: str | None = ...,
docstring: Optional[str] = ..., docstring: str | None = ...,
) -> None: ... ) -> None: ...
def add_member(self, obj: DocObject) -> None: ...
def add_member(self, obj: "DocObject") -> None: ... def get_member(self, name: str) -> DocObject: ...
def get_all_members(self) -> Iterable[DocObject]: ...
def get_member(self, name: str) -> "DocObject": ...
def get_all_members(self) -> Iterable["DocObject"]: ...

View File

@@ -8,7 +8,7 @@ doc-forge to represent a documented codebase. A `Project` aggregates multiple
modules and provides access to them through a unified interface. modules and provides access to them through a unified interface.
""" """
from typing import Dict, Iterable from collections.abc import Iterable
from docforge.models.module import Module from docforge.models.module import Module
@@ -37,7 +37,7 @@ class Project:
Name used to identify the documentation project. Name used to identify the documentation project.
""" """
self.name = name self.name = name
self.modules: Dict[str, Module] = {} self.modules: dict[str, Module] = {}
def add_module(self, module: Module) -> None: def add_module(self, module: Module) -> None:
""" """

View File

@@ -1,20 +1,15 @@
from typing import Dict, Iterable from collections.abc import Iterable
from docforge.models.module import Module from docforge.models.module import Module
class Project: class Project:
"""Represents a documentation project.""" """Represents a documentation project."""
name: str name: str
modules: Dict[str, Module] modules: dict[str, Module]
def __init__(self, name: str) -> None: ... def __init__(self, name: str) -> None: ...
def add_module(self, module: Module) -> None: ... def add_module(self, module: Module) -> None: ...
def get_module(self, path: str) -> Module: ... def get_module(self, path: str) -> Module: ...
def get_all_modules(self) -> Iterable[Module]: ... def get_all_modules(self) -> Iterable[Module]: ...
def get_module_list(self) -> list[str]: ... def get_module_list(self) -> list[str]: ...

View File

@@ -1,6 +1,6 @@
from .spec import NavSpec, load_nav_spec
from .resolver import ResolvedNav, resolve_nav
from .mkdocs import MkDocsNavEmitter from .mkdocs import MkDocsNavEmitter
from .resolver import ResolvedNav, resolve_nav
from .spec import NavSpec, load_nav_spec
__all__ = [ __all__ = [
"NavSpec", "NavSpec",

View File

@@ -7,7 +7,7 @@ MkDocs ``nav`` configuration.
""" """
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any from typing import Any
from docforge.nav.resolver import ResolvedNav from docforge.nav.resolver import ResolvedNav
@@ -20,7 +20,7 @@ class MkDocsNavEmitter:
list structure expected by the MkDocs ``nav`` configuration field. list structure expected by the MkDocs ``nav`` configuration field.
""" """
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]: def emit(self, nav: ResolvedNav) -> list[dict[str, Any]]:
""" """
Generate a navigation structure for ``mkdocs.yml``. Generate a navigation structure for ``mkdocs.yml``.
@@ -33,7 +33,7 @@ class MkDocsNavEmitter:
Each dictionary maps a navigation label to a page or a list of Each dictionary maps a navigation label to a page or a list of
pages. pages.
""" """
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
# Home entry (semantic path) # Home entry (semantic path)
if nav.home: if nav.home:
@@ -41,7 +41,7 @@ class MkDocsNavEmitter:
# Group entries # Group entries
for group, paths in nav.groups.items(): for group, paths in nav.groups.items():
entries: List[str] = [] entries: list[str] = []
for p in paths: for p in paths:
# Convert filesystem path back to docs-relative path # Convert filesystem path back to docs-relative path
rel_path = self._to_relative(p, nav._docs_root) rel_path = self._to_relative(p, nav._docs_root)
@@ -75,7 +75,7 @@ class MkDocsNavEmitter:
path_str = path.as_posix() path_str = path.as_posix()
docs_root_str = docs_root.as_posix() docs_root_str = docs_root.as_posix()
if path_str.startswith(docs_root_str + "/"): if path_str.startswith(docs_root_str + "/"):
return path_str[len(docs_root_str) + 1:] return path_str[len(docs_root_str) + 1 :]
# Fallback for other cases # Fallback for other cases
return path.as_posix().split("/docs/", 1)[-1] return path.as_posix().split("/docs/", 1)[-1]

View File

@@ -1,15 +1,14 @@
from typing import Dict, List, Any
from pathlib import Path from pathlib import Path
from typing import Any
from docforge.nav.resolver import ResolvedNav from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter: class MkDocsNavEmitter:
""" """
Converts a ResolvedNav into MkDocs-compatible `nav` data. Converts a ResolvedNav into MkDocs-compatible `nav` data.
""" """
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]: def emit(self, nav: ResolvedNav) -> list[dict[str, Any]]:
""" """
Emit a structure suitable for insertion into mkdocs.yml. Emit a structure suitable for insertion into mkdocs.yml.

View File

@@ -5,10 +5,9 @@ This module resolves a ``NavSpec`` against the filesystem by expanding glob
patterns and validating that referenced documentation files exist. patterns and validating that referenced documentation files exist.
""" """
from pathlib import Path
from typing import Dict, Iterable, List
import glob import glob
from collections.abc import Iterable
from pathlib import Path
from docforge.nav.spec import NavSpec from docforge.nav.spec import NavSpec
@@ -29,7 +28,7 @@ class ResolvedNav:
def __init__( def __init__(
self, self,
home: str | None, home: str | None,
groups: Dict[str, List[Path]], groups: dict[str, list[Path]],
docs_root: Path | None = None, docs_root: Path | None = None,
) -> None: ) -> None:
""" """
@@ -61,8 +60,7 @@ class ResolvedNav:
yield self._docs_root / self.home yield self._docs_root / self.home
for paths in self.groups.values(): for paths in self.groups.values():
for p in paths: yield from paths
yield p
def resolve_nav( def resolve_nav(
@@ -89,7 +87,7 @@ def resolve_nav(
if not docs_root.exists(): if not docs_root.exists():
raise FileNotFoundError(docs_root) raise FileNotFoundError(docs_root)
def resolve_pattern(pattern: str) -> List[Path]: def resolve_pattern(pattern: str) -> list[Path]:
""" """
Resolve a glob pattern relative to the documentation root. Resolve a glob pattern relative to the documentation root.
@@ -103,9 +101,7 @@ def resolve_nav(
FileNotFoundError: If the pattern does not match any files. FileNotFoundError: If the pattern does not match any files.
""" """
full = docs_root / pattern full = docs_root / pattern
matches = sorted( matches = sorted(Path(p) for p in glob.glob(str(full), recursive=True))
Path(p) for p in glob.glob(str(full), recursive=True)
)
if not matches: if not matches:
raise FileNotFoundError(pattern) raise FileNotFoundError(pattern)
@@ -121,10 +117,10 @@ def resolve_nav(
home = spec.home home = spec.home
# Resolve navigation groups # Resolve navigation groups
resolved_groups: Dict[str, List[Path]] = {} resolved_groups: dict[str, list[Path]] = {}
for group, patterns in spec.groups.items(): for group, patterns in spec.groups.items():
files: List[Path] = [] files: list[Path] = []
for pattern in patterns: for pattern in patterns:
files.extend(resolve_pattern(pattern)) files.extend(resolve_pattern(pattern))
resolved_groups[group] = files resolved_groups[group] = files

View File

@@ -1,9 +1,8 @@
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from typing import Dict, List, Iterable, Optional
from docforge.nav.spec import NavSpec from docforge.nav.spec import NavSpec
class ResolvedNav: class ResolvedNav:
""" """
Fully-resolved navigation tree. Fully-resolved navigation tree.
@@ -13,17 +12,16 @@ class ResolvedNav:
- Order is preserved - Order is preserved
""" """
home: Optional[str] home: str | None
groups: Dict[str, List[Path]] groups: dict[str, list[Path]]
_docs_root: Optional[Path] _docs_root: Path | None
def __init__( def __init__(
self, self,
home: str | None, home: str | None,
groups: Dict[str, List[Path]], groups: dict[str, list[Path]],
docs_root: Path | None = ..., docs_root: Path | None = ...,
) -> None: ... ) -> None: ...
def all_files(self) -> Iterable[Path]: def all_files(self) -> Iterable[Path]:
""" """
Return all resolved documentation files in nav order. Return all resolved documentation files in nav order.
@@ -33,7 +31,6 @@ class ResolvedNav:
""" """
... ...
def resolve_nav( def resolve_nav(
spec: NavSpec, spec: NavSpec,
docs_root: Path, docs_root: Path,

View File

@@ -7,7 +7,6 @@ structure defined by the user in the doc-forge navigation specification
""" """
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional
import yaml import yaml
@@ -28,8 +27,8 @@ class NavSpec:
def __init__( def __init__(
self, self,
home: Optional[str], home: str | None,
groups: Dict[str, List[str]], groups: dict[str, list[str]],
) -> None: ) -> None:
""" """
Initialize a NavSpec instance. Initialize a NavSpec instance.
@@ -85,7 +84,7 @@ class NavSpec:
return cls(home=home, groups=groups) return cls(home=home, groups=groups)
def all_patterns(self) -> List[str]: def all_patterns(self) -> list[str]:
""" """
Return all path patterns referenced by the specification. Return all path patterns referenced by the specification.
@@ -93,7 +92,7 @@ class NavSpec:
A list containing the home document (if defined) and all A list containing the home document (if defined) and all
group pattern entries. group pattern entries.
""" """
patterns: List[str] = [] patterns: list[str] = []
if self.home: if self.home:
patterns.append(self.home) patterns.append(self.home)

View File

@@ -1,6 +1,4 @@
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional
class NavSpec: class NavSpec:
""" """
@@ -10,18 +8,16 @@ class NavSpec:
of filesystem structure or MkDocs specifics. of filesystem structure or MkDocs specifics.
""" """
home: Optional[str] home: str | None
groups: Dict[str, List[str]] groups: dict[str, list[str]]
def __init__( def __init__(
self, self,
home: Optional[str], home: str | None,
groups: Dict[str, List[str]], groups: dict[str, list[str]],
) -> None: ) -> None: ...
...
@classmethod @classmethod
def load(cls, path: Path) -> "NavSpec": def load(cls, path: Path) -> NavSpec:
""" """
Load and validate a nav specification from YAML. Load and validate a nav specification from YAML.
@@ -31,12 +27,11 @@ class NavSpec:
""" """
... ...
def all_patterns(self) -> List[str]: def all_patterns(self) -> list[str]:
""" """
Return all path patterns referenced by the spec Return all path patterns referenced by the spec
(including home and group entries). (including home and group entries).
""" """
... ...
def load_nav_spec(path: Path) -> NavSpec: ... def load_nav_spec(path: Path) -> NavSpec: ...

0
docforge/py.typed Normal file
View File

View File

@@ -1,5 +1,5 @@
from .mkdocs_renderer import MkDocsRenderer
from .mcp_renderer import MCPRenderer from .mcp_renderer import MCPRenderer
from .mkdocs_renderer import MkDocsRenderer
__all__ = [ __all__ = [
"MkDocsRenderer", "MkDocsRenderer",

View File

@@ -3,7 +3,6 @@ from typing import Protocol
from docforge.models import Project from docforge.models import Project
class RendererConfig: class RendererConfig:
"""Renderer configuration container.""" """Renderer configuration container."""
@@ -12,7 +11,6 @@ class RendererConfig:
def __init__(self, out_dir: Path, project: Project) -> None: ... def __init__(self, out_dir: Path, project: Project) -> None: ...
class DocRenderer(Protocol): class DocRenderer(Protocol):
"""Renderer interface.""" """Renderer interface."""

View File

@@ -9,9 +9,8 @@ resources compatible with the Model Context Protocol (MCP).
import json import json
from pathlib import Path from pathlib import Path
from typing import Dict, List
from docforge.models import Project, Module, DocObject from docforge.models import DocObject, Module, Project
class MCPRenderer: class MCPRenderer:
@@ -42,15 +41,17 @@ class MCPRenderer:
modules_dir = out_dir / "modules" modules_dir = out_dir / "modules"
modules_dir.mkdir(parents=True, exist_ok=True) modules_dir.mkdir(parents=True, exist_ok=True)
nav: List[Dict[str, str]] = [] nav: list[dict[str, str]] = []
for module in project.get_all_modules(): for module in project.get_all_modules():
self._write_module(module, modules_dir) self._write_module(module, modules_dir)
nav.append({ nav.append(
"module": module.path, {
"resource": f"doc://modules/{module.path}", "module": module.path,
}) "resource": f"doc://modules/{module.path}",
}
)
# Write nav.json # Write nav.json
(out_dir / "nav.json").write_text( (out_dir / "nav.json").write_text(
@@ -91,7 +92,7 @@ class MCPRenderer:
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(self._json(payload), encoding="utf-8") out.write_text(self._json(payload), encoding="utf-8")
def _render_module(self, module: Module) -> Dict: def _render_module(self, module: Module) -> dict:
""" """
Convert a Module model into MCP-compatible structured data. Convert a Module model into MCP-compatible structured data.
@@ -103,7 +104,7 @@ class MCPRenderer:
Dict: Dict:
Dictionary representing the module and its documented objects. Dictionary representing the module and its documented objects.
""" """
data: Dict = { data: dict = {
"path": module.path, "path": module.path,
"docstring": module.docstring, "docstring": module.docstring,
"objects": {}, "objects": {},
@@ -114,7 +115,7 @@ class MCPRenderer:
return data return data
def _render_object(self, obj: DocObject) -> Dict: def _render_object(self, obj: DocObject) -> dict:
""" """
Recursively convert a DocObject into structured MCP data. Recursively convert a DocObject into structured MCP data.
@@ -126,7 +127,7 @@ class MCPRenderer:
Dict: Dict:
Dictionary describing the object and any nested members. Dictionary describing the object and any nested members.
""" """
data: Dict = { data: dict = {
"name": obj.name, "name": obj.name,
"kind": obj.kind, "kind": obj.kind,
"path": obj.path, "path": obj.path,
@@ -137,14 +138,13 @@ class MCPRenderer:
members = list(obj.get_all_members()) members = list(obj.get_all_members())
if members: if members:
data["members"] = { data["members"] = {
member.name: self._render_object(member) member.name: self._render_object(member) for member in members
for member in members
} }
return data return data
@staticmethod @staticmethod
def _json(data: Dict) -> str: def _json(data: dict) -> str:
""" """
Serialize data to formatted JSON. Serialize data to formatted JSON.

View File

@@ -1,8 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Any
from docforge.models import Project, Module, DocObject
from docforge.models import DocObject, Module, Project
class MCPRenderer: class MCPRenderer:
"""Renderer that emits MCP-native JSON resources from docforge models.""" """Renderer that emits MCP-native JSON resources from docforge models."""
@@ -15,12 +14,12 @@ class MCPRenderer:
def _write_module(self, module: Module, modules_dir: Path) -> None: def _write_module(self, module: Module, modules_dir: Path) -> None:
"""Serialize a module into an MCP JSON resource.""" """Serialize a module into an MCP JSON resource."""
def _render_module(self, module: Module) -> Dict: def _render_module(self, module: Module) -> dict[str, Any]:
"""Render a Module into MCP-friendly structured data.""" """Render a Module into MCP-friendly structured data."""
def _render_object(self, obj: DocObject) -> Dict: def _render_object(self, obj: DocObject) -> dict[str, Any]:
"""Recursively render a DocObject into structured MCP data.""" """Recursively render a DocObject into structured MCP data."""
@staticmethod @staticmethod
def _json(data: Dict) -> str: def _json(data: dict[str, Any]) -> str:
"""Serialize structured data to formatted JSON.""" """Serialize structured data to formatted JSON."""

View File

@@ -16,7 +16,8 @@ The renderer ensures a consistent documentation structure by:
""" """
from pathlib import Path from pathlib import Path
from docforge.models import Project, Module
from docforge.models import Module, Project
class MkDocsRenderer: class MkDocsRenderer:
@@ -65,8 +66,7 @@ class MkDocsRenderer:
# Detect packages (modules with children) # Detect packages (modules with children)
packages = { packages = {
p for p in paths p for p in paths if any(other.startswith(p + ".") for other in paths)
if any(other.startswith(p + ".") for other in paths)
} }
for module in modules: for module in modules:
@@ -127,12 +127,12 @@ class MkDocsRenderer:
str(root_module.docstring), str(root_module.docstring),
) )
content = ( content = f"# {project.name}\n\n" f"{doc.strip()}\n"
f"# {project.name}\n\n"
f"{doc.strip()}\n"
)
if not readme_path.exists() or readme_path.read_text(encoding="utf-8") != content: if (
not readme_path.exists()
or readme_path.read_text(encoding="utf-8") != content
):
readme_path.write_text( readme_path.write_text(
content, content,
encoding="utf-8", encoding="utf-8",
@@ -231,10 +231,7 @@ class MkDocsRenderer:
str: str:
Markdown source containing a mkdocstrings directive. Markdown source containing a mkdocstrings directive.
""" """
return ( return f"# {title}\n\n" f"::: {module_path}\n"
f"# {title}\n\n"
f"::: {module_path}\n"
)
def _ensure_root_index( def _ensure_root_index(
self, self,
@@ -255,8 +252,7 @@ class MkDocsRenderer:
if not root_index.exists(): if not root_index.exists():
root_index.write_text( root_index.write_text(
f"# {project.name}\n\n" f"# {project.name}\n\n" "## Modules\n\n",
"## Modules\n\n",
encoding="utf-8", encoding="utf-8",
) )

View File

@@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from docforge.models import Project, Module
from docforge.models import Module, Project
class MkDocsRenderer: class MkDocsRenderer:
name: str name: str
@@ -11,14 +11,12 @@ class MkDocsRenderer:
out_dir: Path, out_dir: Path,
module_is_source: bool | None = None, module_is_source: bool | None = None,
) -> None: ... ) -> None: ...
def generate_readme( def generate_readme(
self, self,
project: Project, project: Project,
docs_dir: Path, docs_dir: Path,
module_is_source: bool | None = None, module_is_source: bool | None = None,
) -> None: ) -> None: ...
def _write_module( def _write_module(
self, self,
module: Module, module: Module,
@@ -26,9 +24,8 @@ class MkDocsRenderer:
out_dir: Path, out_dir: Path,
module_is_source: bool | None = None, module_is_source: bool | None = None,
) -> None: ... ) -> None: ...
def _render_markdown(self, title: str, module_path: str) -> str: ... def _render_markdown(self, title: str, module_path: str) -> str: ...
def _ensure_root_index(self, project: Project, out_dir: Path) -> None: ...
def _ensure_root_index(self, project, out_dir) -> None: ... def _ensure_parent_index(
self, parts: list[str], out_dir: Path, link_target: str, title: str
def _ensure_parent_index(self, parts, out_dir, link_target, title) -> None: ... ) -> None: ...

View File

@@ -96,9 +96,7 @@ class MCPServer:
@self.app.resource("docs://modules/{module}") @self.app.resource("docs://modules/{module}")
def module(module: str): def module(module: str):
return self._read_json( return self._read_json(self.mcp_root / "modules" / f"{module}.json")
self.mcp_root / "modules" / f"{module}.json"
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# MCP tools # MCP tools

View File

@@ -1,9 +1,8 @@
from pathlib import Path from pathlib import Path
from typing import Literal, Any from typing import Any, Literal
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
class MCPServer: class MCPServer:
"""MCP server for serving documentation.""" """MCP server for serving documentation."""
@@ -11,12 +10,8 @@ class MCPServer:
app: FastMCP app: FastMCP
def __init__(self, mcp_root: Path, name: str) -> None: ... def __init__(self, mcp_root: Path, name: str) -> None: ...
def _read_json(self, path: Path) -> Any: ... def _read_json(self, path: Path) -> Any: ...
def _register_resources(self) -> None: ... def _register_resources(self) -> None: ...
def _register_tools(self) -> None: ... def _register_tools(self) -> None: ...
def run(self, transport: Literal["stdio", "sse", "streamable-http"] = ...) -> None: def run(self, transport: Literal["stdio", "sse", "streamable-http"] = ...) -> None:
"""Start the MCP server.""" """Start the MCP server."""

View File

@@ -17,6 +17,19 @@ authors = [
maintainers = [ maintainers = [
{ name = "Aetos Skia", email = "dev@aetoskia.com" } { name = "Aetos Skia", email = "dev@aetoskia.com" }
] ]
keywords = [
"documentation",
"docs",
"compiler",
"mkdocs",
"mcp",
"griffe",
"docstrings",
"sphinx",
]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
@@ -25,9 +38,12 @@ classifiers = [
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: FastAPI", "Framework :: FastAPI",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Documentation",
"Typing :: Typed",
] ]
@@ -57,14 +73,25 @@ sphinx = [
"sphinx-autodoc-typehints>=1.19.0", "sphinx-autodoc-typehints>=1.19.0",
] ]
mcp = [ mcp = [
"mcp>=1.0.0", "mcp>=1.0.0,<2.0.0",
] ]
dev = [ dev = [
"pytest>=7.0.0", "pytest>=8.0.0",
"pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"black>=23.0.0", "black>=23.0.0",
"ruff>=0.1.0", "ruff>=0.3.0",
"mypy>=1.0.0", "mypy>=1.8.0",
"build>=1.0.0",
"twine>=4.0.0",
"pre-commit>=3.4.0",
]
docs = [
"doc-forge[mkdocs]",
]
all = [
"doc-forge[dev,docs,mcp]",
] ]
@@ -80,15 +107,100 @@ Versions = "https://git.aetoskia.com/aetos/doc-forge/tags"
packages = { find = { include = ["docforge*"] } } packages = { find = { include = ["docforge*"] } }
[tool.setuptools.package-data] [tool.setuptools.package-data]
docforge = ["templates/*.yml"] docforge = ["py.typed", "templates/*.yml"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--strict-config",
"--cov=docforge",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-report=xml",
]
[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312", "py313"]
include = '\.pyi?$'
extend-exclude = '''
/(
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 88
target-version = "py310" target-version = "py310"
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"B",
"C4",
"UP",
]
ignore = [
"E501",
"B008",
"C901",
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001"]
"tests/*" = ["B008"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
strict = true strict = true
exclude = [
"tests/",
]
files = ["docforge"]
[[tool.mypy.overrides]]
module = [
"griffe.*",
"mcp.*",
"click",
"pydantic",
]
ignore_missing_imports = true ignore_missing_imports = true
[tool.coverage.run]
source = ["docforge"]
omit = [
"*/tests/*",
"*/test_*.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise AssertionError",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"@abstractmethod",
]

View File

@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
from docforge.cli.main import cli from docforge.cli.main import cli
def test_mcp_build(cli_runner): def test_mcp_build(cli_runner):
with cli_runner.isolated_filesystem(): with cli_runner.isolated_filesystem():
cwd = Path.cwd() cwd = Path.cwd()

View File

@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
from docforge.cli.main import cli from docforge.cli.main import cli
def test_mkdocs_build_full_flow( def test_mkdocs_build_full_flow(
cli_runner, cli_runner,
mock_mkdocs_build, mock_mkdocs_build,
@@ -43,11 +45,13 @@ def test_mkdocs_build_full_flow(
assert (cwd / "mkdocs.yml").exists() assert (cwd / "mkdocs.yml").exists()
assert (cwd / "docs" / "testpkg" / "mod.md").exists() assert (cwd / "docs" / "testpkg" / "mod.md").exists()
def test_mkdocs_build_missing_module_fails(cli_runner): def test_mkdocs_build_missing_module_fails(cli_runner):
result = cli_runner.invoke(cli, ["build", "--mkdocs", "--site-name", "Test"]) result = cli_runner.invoke(cli, ["build", "--mkdocs", "--site-name", "Test"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "--module is required" in result.output assert "--module is required" in result.output
def test_mkdocs_build_without_site_name_uses_module_as_default_full_flow( def test_mkdocs_build_without_site_name_uses_module_as_default_full_flow(
cli_runner, cli_runner,
mock_mkdocs_build, mock_mkdocs_build,

View File

@@ -1,5 +1,6 @@
from docforge.cli.main import cli from docforge.cli.main import cli
def test_mcp_serve( def test_mcp_serve(
cli_runner, cli_runner,
fake_mcp_docs, fake_mcp_docs,

View File

@@ -1,5 +1,6 @@
from docforge.cli.main import cli from docforge.cli.main import cli
def test_mkdocs_serve( def test_mkdocs_serve(
cli_runner, cli_runner,
fake_mkdocs_yml, fake_mkdocs_yml,

View File

@@ -1,8 +1,8 @@
import sys
import json import json
import pytest import sys
from pathlib import Path from pathlib import Path
import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -45,6 +45,7 @@ plugins:
@pytest.fixture @pytest.fixture
def mock_mkdocs_load_config(monkeypatch): def mock_mkdocs_load_config(monkeypatch):
"""Mock mkdocs.config.load_config.""" """Mock mkdocs.config.load_config."""
def fake_load_config(path): def fake_load_config(path):
return object() # dummy config object return object() # dummy config object
@@ -81,6 +82,7 @@ def mock_mkdocs_serve(monkeypatch):
) )
return lambda: called["value"] return lambda: called["value"]
@pytest.fixture @pytest.fixture
def fake_mcp_docs(tmp_path: Path) -> Path: def fake_mcp_docs(tmp_path: Path) -> Path:
""" """

View File

@@ -2,11 +2,9 @@ from docforge import GriffeLoader
def test_alias_does_not_crash(temp_package): def test_alias_does_not_crash(temp_package):
(temp_package / "alias.py").write_text( (temp_package / "alias.py").write_text("""from typing import List
'''from typing import List
Alias = List[int] Alias = List[int]
''' """)
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.alias"]) project = loader.load_project(["testpkg.alias"])

View File

@@ -2,15 +2,13 @@ from docforge import GriffeLoader
def test_class_and_methods(temp_package): def test_class_and_methods(temp_package):
(temp_package / "cls.py").write_text( (temp_package / "cls.py").write_text('''class MyClass:
'''class MyClass:
"""Class doc.""" """Class doc."""
def method(self, x: int) -> int: def method(self, x: int) -> int:
"""Method doc.""" """Method doc."""
return x return x
''' ''')
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.cls"]) project = loader.load_project(["testpkg.cls"])

View File

@@ -2,12 +2,10 @@ from docforge import GriffeLoader
def test_function_signature(temp_package): def test_function_signature(temp_package):
(temp_package / "fn.py").write_text( (temp_package / "fn.py").write_text('''def add(a: int, b: int = 1) -> int:
'''def add(a: int, b: int = 1) -> int:
"""Adds numbers.""" """Adds numbers."""
return a + b return a + b
''' ''')
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.fn"]) project = loader.load_project(["testpkg.fn"])

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from docforge import GriffeLoader from docforge import GriffeLoader
@@ -6,9 +7,8 @@ def test_load_project_raises_on_missing_module_by_default():
loader = GriffeLoader() loader = GriffeLoader()
with pytest.raises(ImportError): with pytest.raises(ImportError):
loader.load_project( loader.load_project(["nonexistent.module", "sys"])
["nonexistent.module", "sys"]
)
def test_load_project_skips_missing_modules_when_enabled(): def test_load_project_skips_missing_modules_when_enabled():
loader = GriffeLoader() loader = GriffeLoader()

View File

@@ -2,9 +2,7 @@ from docforge import GriffeLoader
def test_missing_docstrings(temp_package): def test_missing_docstrings(temp_package):
(temp_package / "nodoc.py").write_text( (temp_package / "nodoc.py").write_text("""def f(): pass""")
'''def f(): pass'''
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.nodoc"]) project = loader.load_project(["testpkg.nodoc"])

View File

@@ -2,11 +2,9 @@ from docforge import GriffeLoader
def test_private_members_excluded(temp_package): def test_private_members_excluded(temp_package):
(temp_package / "priv.py").write_text( (temp_package / "priv.py").write_text("""def _hidden(): pass
'''def _hidden(): pass
def visible(): pass def visible(): pass
''' """)
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.priv"]) project = loader.load_project(["testpkg.priv"])

View File

@@ -2,13 +2,11 @@ from docforge import GriffeLoader
def test_load_single_module(temp_package): def test_load_single_module(temp_package):
(temp_package / "mod.py").write_text( (temp_package / "mod.py").write_text('''"""Module docstring."""\n
'''"""Module docstring."""\n
def foo(): def foo():
"""Foo docstring.""" """Foo docstring."""
pass pass
''' ''')
)
loader = GriffeLoader() loader = GriffeLoader()
project = loader.load_project(["testpkg.mod"]) project = loader.load_project(["testpkg.mod"])

View File

@@ -1,7 +1,6 @@
from pathlib import Path from pathlib import Path
from docforge.nav import ResolvedNav from docforge.nav import MkDocsNavEmitter, ResolvedNav
from docforge.nav import MkDocsNavEmitter
def test_emit_mkdocs_nav(): def test_emit_mkdocs_nav():

View File

@@ -2,8 +2,7 @@ from pathlib import Path
import pytest import pytest
from docforge.nav import NavSpec from docforge.nav import NavSpec, resolve_nav
from docforge.nav import resolve_nav
def _write_docs(root: Path, paths: list[str]) -> None: def _write_docs(root: Path, paths: list[str]) -> None:

View File

@@ -2,7 +2,7 @@ import json
from pathlib import Path from pathlib import Path
from docforge import MCPRenderer from docforge import MCPRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mcp_file_content(tmp_path: Path): def test_mcp_file_content(tmp_path: Path):

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge import MCPRenderer from docforge import MCPRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mcp_idempotent(tmp_path: Path): def test_mcp_idempotent(tmp_path: Path):

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge.loaders import GriffeLoader, discover_module_paths
from docforge import MCPRenderer from docforge import MCPRenderer
from docforge.loaders import GriffeLoader, discover_module_paths
def test_mcp_emits_all_modules(tmp_path: Path) -> None: def test_mcp_emits_all_modules(tmp_path: Path) -> None:
@@ -22,10 +22,7 @@ def test_mcp_emits_all_modules(tmp_path: Path) -> None:
for p in (tmp_path / "modules").rglob("*.json") for p in (tmp_path / "modules").rglob("*.json")
} }
expected = { expected = {f"modules/{m.path}.json" for m in project.get_all_modules()}
f"modules/{m.path}.json"
for m in project.get_all_modules()
}
missing = expected - emitted missing = expected - emitted
assert not missing, f"Missing MCP module JSON files: {missing}" assert not missing, f"Missing MCP module JSON files: {missing}"

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge import MCPRenderer from docforge import MCPRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mcp_directory_structure(tmp_path: Path): def test_mcp_directory_structure(tmp_path: Path):

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge import MkDocsRenderer from docforge import MkDocsRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mkdocs_file_content(tmp_path: Path): def test_mkdocs_file_content(tmp_path: Path):

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge import MkDocsRenderer from docforge import MkDocsRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mkdocs_idempotent(tmp_path: Path): def test_mkdocs_idempotent(tmp_path: Path):

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge.loaders import GriffeLoader, discover_module_paths
from docforge import MkDocsRenderer from docforge import MkDocsRenderer
from docforge.loaders import GriffeLoader, discover_module_paths
def test_mkdocs_emits_all_modules(tmp_path: Path) -> None: def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
@@ -23,10 +23,7 @@ def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
module_is_source=True, module_is_source=True,
) )
emitted = { emitted = {p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*.md")}
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()] module_paths = [m.path for m in project.get_all_modules()]
@@ -34,8 +31,7 @@ def test_mkdocs_emits_all_modules(tmp_path: Path) -> None:
for path in module_paths: for path in module_paths:
parts = path.split(".") parts = path.split(".")
is_package = any( is_package = any(
other != path and other.startswith(path + ".") other != path and other.startswith(path + ".") for other in module_paths
for other in module_paths
) )
if is_package: if is_package:

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from docforge import MkDocsRenderer from docforge import MkDocsRenderer
from docforge.models import Project, Module from docforge.models import Module, Project
def test_mkdocs_directory_structure(tmp_path: Path): def test_mkdocs_directory_structure(tmp_path: Path):