standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:04 +05:30
parent 8253c25928
commit 0129268cd3
61 changed files with 429 additions and 273 deletions

View File

@@ -32,6 +32,26 @@ steps:
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
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
image: python:3.13-slim
commands:
@@ -126,4 +146,4 @@ steps:
trigger:
event:
- custom
- custom

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 .cli import main
from .loaders import GriffeLoader, discover_module_paths
from .renderers import MCPRenderer, MkDocsRenderer
__all__ = [
"GriffeLoader",

View File

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

View File

@@ -1,5 +1,3 @@
from .main import main
__all__ = [
"main"
]
__all__ = ["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.
"""
import click
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.cli import mkdocs_utils
from docforge.cli import mcp_utils
@click.group()
@@ -28,26 +28,52 @@ def cli() -> None:
@cli.command()
@click.option("--mcp", is_flag=True, help="Build MCP resources")
@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("--project-name", help="Project name override")
@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("--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")
@click.option(
"--docs-dir",
type=click.Path(path_type=Path),
default=Path("docs"),
help="Directory for MD sources",
)
@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(
mcp: bool,
mkdocs: bool,
module_is_source: bool,
module: Optional[str],
project_name: Optional[str],
site_name: Optional[str],
module: str | None,
project_name: str | None,
site_name: str | None,
docs_dir: Path,
nav_file: Path,
template: Optional[Path],
template: Path | None,
mkdocs_yml: Path,
out_dir: Path,
) -> None:
@@ -121,7 +147,9 @@ def build(
)
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...")
mkdocs_utils.build(mkdocs_yml)
@@ -140,12 +168,22 @@ def build(
@click.option("--mcp", is_flag=True, help="Serve MCP documentation")
@click.option("--mkdocs", is_flag=True, help="Serve MkDocs site")
@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("--out-dir", type=click.Path(path_type=Path), default=Path("mcp_docs"), help="MCP root directory")
@click.option(
"--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(
mcp: bool,
mkdocs: bool,
module: Optional[str],
module: str | None,
mkdocs_yml: Path,
out_dir: Path,
) -> None:
@@ -202,7 +240,7 @@ def serve(
)
def tree(
module: str,
project_name: Optional[str],
project_name: str | None,
) -> None:
"""
Display the documentation object tree for a module.

View File

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

View File

@@ -22,4 +22,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()

View File

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

View File

@@ -1,4 +1,6 @@
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: ...

View File

@@ -4,13 +4,15 @@
Utilities for working with MkDocs in the doc-forge CLI.
"""
from pathlib import Path
from importlib import resources
from pathlib import Path
import click
import yaml
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.nav import load_nav_spec, resolve_nav, MkDocsNavEmitter
def generate_sources(
@@ -138,8 +140,8 @@ def build(mkdocs_yml: Path) -> None:
if not mkdocs_yml.exists():
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.config import load_config
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}")
from mkdocs.commands.serve import serve as mkdocs_serve
mkdocs_serve(config_file=str(mkdocs_yml))

View File

@@ -6,6 +6,8 @@ def generate_sources(
project_name: str | None = None,
module_is_source: bool | 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 serve(mkdocs_yml: Path) -> None: ...

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
from typing import Dict, Iterable, Optional
from collections.abc import Iterable
class DocObject:
"""Represents a documented Python object."""
@@ -7,21 +6,18 @@ class DocObject:
name: str
kind: str
path: str
signature: Optional[str]
docstring: Optional[str]
members: Dict[str, "DocObject"]
signature: str | None
docstring: str | None
members: dict[str, DocObject]
def __init__(
self,
name: str,
kind: str,
path: str,
signature: Optional[str] = ...,
docstring: Optional[str] = ...,
signature: str | None = ...,
docstring: str | None = ...,
) -> None: ...
def add_member(self, obj: "DocObject") -> None: ...
def get_member(self, name: str) -> "DocObject": ...
def get_all_members(self) -> Iterable["DocObject"]: ...
def add_member(self, obj: DocObject) -> None: ...
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.
"""
from typing import Dict, Iterable
from collections.abc import Iterable
from docforge.models.module import Module
@@ -37,7 +37,7 @@ class Project:
Name used to identify the documentation project.
"""
self.name = name
self.modules: Dict[str, Module] = {}
self.modules: dict[str, Module] = {}
def add_module(self, module: Module) -> None:
"""
@@ -85,4 +85,4 @@ class Project:
list[str]:
A list containing the dotted paths of all modules in the project.
"""
return list(self.modules.keys())
return list(self.modules.keys())

View File

@@ -1,20 +1,15 @@
from typing import Dict, Iterable
from collections.abc import Iterable
from docforge.models.module import Module
class Project:
"""Represents a documentation project."""
name: str
modules: Dict[str, Module]
modules: dict[str, Module]
def __init__(self, name: str) -> None: ...
def add_module(self, module: Module) -> None: ...
def get_module(self, path: str) -> Module: ...
def get_all_modules(self) -> Iterable[Module]: ...
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 .resolver import ResolvedNav, resolve_nav
from .spec import NavSpec, load_nav_spec
__all__ = [
"NavSpec",

View File

@@ -7,7 +7,7 @@ MkDocs ``nav`` configuration.
"""
from pathlib import Path
from typing import List, Dict, Any
from typing import Any
from docforge.nav.resolver import ResolvedNav
@@ -20,7 +20,7 @@ class MkDocsNavEmitter:
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``.
@@ -33,7 +33,7 @@ class MkDocsNavEmitter:
Each dictionary maps a navigation label to a page or a list of
pages.
"""
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
# Home entry (semantic path)
if nav.home:
@@ -41,7 +41,7 @@ class MkDocsNavEmitter:
# Group entries
for group, paths in nav.groups.items():
entries: List[str] = []
entries: list[str] = []
for p in paths:
# Convert filesystem path back to docs-relative path
rel_path = self._to_relative(p, nav._docs_root)
@@ -75,7 +75,7 @@ class MkDocsNavEmitter:
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:]
return path_str[len(docs_root_str) + 1 :]
# Fallback for other cases
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 typing import Any
from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""
Converts a ResolvedNav into MkDocs-compatible `nav` data.
"""
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
def emit(self, nav: ResolvedNav) -> list[dict[str, Any]]:
"""
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.
"""
from pathlib import Path
from typing import Dict, Iterable, List
import glob
from collections.abc import Iterable
from pathlib import Path
from docforge.nav.spec import NavSpec
@@ -29,7 +28,7 @@ class ResolvedNav:
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
groups: dict[str, list[Path]],
docs_root: Path | None = None,
) -> None:
"""
@@ -61,8 +60,7 @@ class ResolvedNav:
yield self._docs_root / self.home
for paths in self.groups.values():
for p in paths:
yield p
yield from paths
def resolve_nav(
@@ -89,7 +87,7 @@ def resolve_nav(
if not docs_root.exists():
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.
@@ -103,9 +101,7 @@ def resolve_nav(
FileNotFoundError: If the pattern does not match any files.
"""
full = docs_root / pattern
matches = sorted(
Path(p) for p in glob.glob(str(full), recursive=True)
)
matches = sorted(Path(p) for p in glob.glob(str(full), recursive=True))
if not matches:
raise FileNotFoundError(pattern)
@@ -121,10 +117,10 @@ def resolve_nav(
home = spec.home
# Resolve navigation groups
resolved_groups: Dict[str, List[Path]] = {}
resolved_groups: dict[str, list[Path]] = {}
for group, patterns in spec.groups.items():
files: List[Path] = []
files: list[Path] = []
for pattern in patterns:
files.extend(resolve_pattern(pattern))
resolved_groups[group] = files

View File

@@ -1,9 +1,8 @@
from collections.abc import Iterable
from pathlib import Path
from typing import Dict, List, Iterable, Optional
from docforge.nav.spec import NavSpec
class ResolvedNav:
"""
Fully-resolved navigation tree.
@@ -13,17 +12,16 @@ class ResolvedNav:
- Order is preserved
"""
home: Optional[str]
groups: Dict[str, List[Path]]
_docs_root: Optional[Path]
home: str | None
groups: dict[str, list[Path]]
_docs_root: Path | None
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
groups: dict[str, list[Path]],
docs_root: Path | None = ...,
) -> None: ...
def all_files(self) -> Iterable[Path]:
"""
Return all resolved documentation files in nav order.
@@ -33,7 +31,6 @@ class ResolvedNav:
"""
...
def resolve_nav(
spec: NavSpec,
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 typing import Dict, List, Optional
import yaml
@@ -28,8 +27,8 @@ class NavSpec:
def __init__(
self,
home: Optional[str],
groups: Dict[str, List[str]],
home: str | None,
groups: dict[str, list[str]],
) -> None:
"""
Initialize a NavSpec instance.
@@ -85,7 +84,7 @@ class NavSpec:
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.
@@ -93,7 +92,7 @@ class NavSpec:
A list containing the home document (if defined) and all
group pattern entries.
"""
patterns: List[str] = []
patterns: list[str] = []
if self.home:
patterns.append(self.home)

View File

@@ -1,6 +1,4 @@
from pathlib import Path
from typing import Dict, List, Optional
class NavSpec:
"""
@@ -10,18 +8,16 @@ class NavSpec:
of filesystem structure or MkDocs specifics.
"""
home: Optional[str]
groups: Dict[str, List[str]]
home: str | None
groups: dict[str, list[str]]
def __init__(
self,
home: Optional[str],
groups: Dict[str, List[str]],
) -> None:
...
home: str | None,
groups: dict[str, list[str]],
) -> None: ...
@classmethod
def load(cls, path: Path) -> "NavSpec":
def load(cls, path: Path) -> NavSpec:
"""
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
(including home and group entries).
"""
...
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 .mkdocs_renderer import MkDocsRenderer
__all__ = [
"MkDocsRenderer",

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
from pathlib import Path
from typing import Dict, List
from docforge.models import Project, Module, DocObject
from typing import Any
from docforge.models import DocObject, Module, Project
class MCPRenderer:
"""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:
"""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."""
def _render_object(self, obj: DocObject) -> Dict:
def _render_object(self, obj: DocObject) -> dict[str, Any]:
"""Recursively render a DocObject into structured MCP data."""
@staticmethod
def _json(data: Dict) -> str:
def _json(data: dict[str, Any]) -> str:
"""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 docforge.models import Project, Module
from docforge.models import Module, Project
class MkDocsRenderer:
@@ -65,8 +66,7 @@ class MkDocsRenderer:
# Detect packages (modules with children)
packages = {
p for p in paths
if any(other.startswith(p + ".") for other in paths)
p for p in paths if any(other.startswith(p + ".") for other in paths)
}
for module in modules:
@@ -127,12 +127,12 @@ class MkDocsRenderer:
str(root_module.docstring),
)
content = (
f"# {project.name}\n\n"
f"{doc.strip()}\n"
)
content = 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(
content,
encoding="utf-8",
@@ -231,10 +231,7 @@ class MkDocsRenderer:
str:
Markdown source containing a mkdocstrings directive.
"""
return (
f"# {title}\n\n"
f"::: {module_path}\n"
)
return f"# {title}\n\n" f"::: {module_path}\n"
def _ensure_root_index(
self,
@@ -255,8 +252,7 @@ class MkDocsRenderer:
if not root_index.exists():
root_index.write_text(
f"# {project.name}\n\n"
"## Modules\n\n",
f"# {project.name}\n\n" "## Modules\n\n",
encoding="utf-8",
)

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,19 @@ authors = [
maintainers = [
{ name = "Aetos Skia", email = "dev@aetoskia.com" }
]
keywords = [
"documentation",
"docs",
"compiler",
"mkdocs",
"mcp",
"griffe",
"docstrings",
"sphinx",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
@@ -25,9 +38,12 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: FastAPI",
"Topic :: Software Development :: Libraries",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Documentation",
"Typing :: Typed",
]
@@ -57,14 +73,25 @@ sphinx = [
"sphinx-autodoc-typehints>=1.19.0",
]
mcp = [
"mcp>=1.0.0",
"mcp>=1.0.0,<2.0.0",
]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"black>=23.0.0",
"ruff>=0.1.0",
"mypy>=1.0.0",
"ruff>=0.3.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*"] } }
[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]
line-length = 100
line-length = 88
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]
python_version = "3.10"
strict = true
exclude = [
"tests/",
]
files = ["docforge"]
[[tool.mypy.overrides]]
module = [
"griffe.*",
"mcp.*",
"click",
"pydantic",
]
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 docforge.cli.main import cli
def test_mcp_build(cli_runner):
with cli_runner.isolated_filesystem():
cwd = Path.cwd()

View File

@@ -1,6 +1,8 @@
from pathlib import Path
from docforge.cli.main import cli
def test_mkdocs_build_full_flow(
cli_runner,
mock_mkdocs_build,
@@ -17,11 +19,11 @@ def test_mkdocs_build_full_flow(
nav_file = cwd / "docforge.nav.yml"
nav_file.write_text("home: testpkg/index.md\ngroups: {}\n")
# We need to create a dummy testpkg/index.md for nav resolution if it's there
# But generate_sources will create it.
# But generate_sources will create it.
# Wait, the current logic runs generate_sources first, THEN generate_config.
result = cli_runner.invoke(
cli,
[
@@ -43,11 +45,13 @@ def test_mkdocs_build_full_flow(
assert (cwd / "mkdocs.yml").exists()
assert (cwd / "docs" / "testpkg" / "mod.md").exists()
def test_mkdocs_build_missing_module_fails(cli_runner):
result = cli_runner.invoke(cli, ["build", "--mkdocs", "--site-name", "Test"])
assert result.exit_code != 0
assert "--module is required" in result.output
def test_mkdocs_build_without_site_name_uses_module_as_default_full_flow(
cli_runner,
mock_mkdocs_build,

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import sys
import json
import pytest
import sys
from pathlib import Path
import pytest
from click.testing import CliRunner
@@ -45,6 +45,7 @@ plugins:
@pytest.fixture
def mock_mkdocs_load_config(monkeypatch):
"""Mock mkdocs.config.load_config."""
def fake_load_config(path):
return object() # dummy config object
@@ -81,6 +82,7 @@ def mock_mkdocs_serve(monkeypatch):
)
return lambda: called["value"]
@pytest.fixture
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):
(temp_package / "alias.py").write_text(
'''from typing import List
(temp_package / "alias.py").write_text("""from typing import List
Alias = List[int]
'''
)
""")
loader = GriffeLoader()
project = loader.load_project(["testpkg.alias"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
from pathlib import Path
from docforge.loaders import GriffeLoader, discover_module_paths
from docforge import MCPRenderer
from docforge.loaders import GriffeLoader, discover_module_paths
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")
}
expected = {
f"modules/{m.path}.json"
for m in project.get_all_modules()
}
expected = {f"modules/{m.path}.json" for m in project.get_all_modules()}
missing = expected - emitted
assert not missing, f"Missing MCP module JSON files: {missing}"

View File

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

View File

@@ -1,7 +1,7 @@
from pathlib import Path
from docforge import MkDocsRenderer
from docforge.models import Project, Module
from docforge.models import Module, Project
def test_mkdocs_file_content(tmp_path: Path):
@@ -47,4 +47,4 @@ def test_generate_readme_source_root(tmp_path: Path):
content = readme.read_text()
assert "# testpkg" in content
assert "Test package documentation." in content
assert "Test package documentation." in content

View File

@@ -1,7 +1,7 @@
from pathlib import Path
from docforge import MkDocsRenderer
from docforge.models import Project, Module
from docforge.models import Module, Project
def test_mkdocs_idempotent(tmp_path: Path):
@@ -50,4 +50,4 @@ def test_generate_readme_idempotent(tmp_path: Path):
second = readme.read_text()
assert first == second
assert first == second

View File

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

View File

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