doctracker 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doctracker/__init__.py +5 -0
- doctracker/__main__.py +4 -0
- doctracker/cli.py +55 -0
- doctracker/config.py +65 -0
- doctracker/extraction/__init__.py +5 -0
- doctracker/extraction/griffe_loader.py +148 -0
- doctracker/server/__init__.py +1 -0
- doctracker/server/app.py +78 -0
- doctracker/server/guide.md +62 -0
- doctracker/server/guide.py +68 -0
- doctracker/server/state.py +32 -0
- doctracker/server/tools.py +76 -0
- doctracker/server/watcher.py +63 -0
- doctracker/server/web.py +120 -0
- doctracker/wiki/__init__.py +1 -0
- doctracker/wiki/pages.py +356 -0
- doctracker/wiki/static/htmx-LICENSE.txt +13 -0
- doctracker/wiki/static/htmx.min.js +1 -0
- doctracker/wiki/static/live.js +178 -0
- doctracker/wiki/static/style.css +424 -0
- doctracker/wiki/templates/base.html +33 -0
- doctracker/wiki/templates/entity.html +97 -0
- doctracker/wiki/templates/fragment.html +3 -0
- doctracker/wiki/templates/fragment_content.html +5 -0
- doctracker/wiki/templates/header.html +20 -0
- doctracker/wiki/templates/loading.html +2 -0
- doctracker/wiki/templates/members.html +18 -0
- doctracker/wiki/templates/module.html +19 -0
- doctracker/wiki/templates/not_found.html +6 -0
- doctracker/wiki/templates/search.html +21 -0
- doctracker/wiki/templates/source.html +1 -0
- doctracker/wiki/templates/tree.html +31 -0
- doctracker-0.1.0.dist-info/METADATA +234 -0
- doctracker-0.1.0.dist-info/RECORD +38 -0
- doctracker-0.1.0.dist-info/WHEEL +4 -0
- doctracker-0.1.0.dist-info/entry_points.txt +3 -0
- doctracker-0.1.0.dist-info/licenses/LICENSE +21 -0
- doctracker-0.1.0.dist-info/licenses/src/doctracker/wiki/static/htmx-LICENSE.txt +13 -0
doctracker/__init__.py
ADDED
doctracker/__main__.py
ADDED
doctracker/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Command line interface: `doctracker init` and `doctracker start`."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from doctracker import __version__
|
|
8
|
+
from doctracker.config import CONFIG_PATH, Config, detect_packages, load_config, write_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.version_option(__version__, prog_name="doctracker")
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Centralized documentation for Python projects developed with LLM agents."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing configuration.")
|
|
19
|
+
def init(force: bool) -> None:
|
|
20
|
+
"""Create doc/config/config.json in the current project."""
|
|
21
|
+
project_root = Path.cwd()
|
|
22
|
+
if (project_root / CONFIG_PATH).exists() and not force:
|
|
23
|
+
raise click.ClickException(f"{CONFIG_PATH.as_posix()} already exists (use --force to overwrite).")
|
|
24
|
+
_initialize(project_root)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.option("--host", help="Override the host from the configuration.")
|
|
29
|
+
@click.option("--port", type=int, help="Override the port from the configuration.")
|
|
30
|
+
def start(host: str | None, port: int | None) -> None:
|
|
31
|
+
"""Start the server (MCP endpoint, wiki and watcher) for the current project."""
|
|
32
|
+
from doctracker.server.app import run
|
|
33
|
+
|
|
34
|
+
project_root = Path.cwd()
|
|
35
|
+
if not (project_root / CONFIG_PATH).exists():
|
|
36
|
+
_initialize(project_root)
|
|
37
|
+
config = load_config(project_root)
|
|
38
|
+
if host:
|
|
39
|
+
config.host = host
|
|
40
|
+
if port:
|
|
41
|
+
config.port = port
|
|
42
|
+
if not config.packages:
|
|
43
|
+
raise click.ClickException(f"No packages to document. Add them to {CONFIG_PATH.as_posix()}.")
|
|
44
|
+
scheme = "https" if config.ssl_certfile else "http"
|
|
45
|
+
click.echo(f"Wiki: {scheme}://{config.host}:{config.port}/")
|
|
46
|
+
click.echo(f"MCP: {scheme}://{config.host}:{config.port}/mcp")
|
|
47
|
+
run(project_root, config)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _initialize(project_root: Path) -> None:
|
|
51
|
+
config = Config()
|
|
52
|
+
config.packages = detect_packages(project_root, config.search_paths)
|
|
53
|
+
write_config(project_root, config)
|
|
54
|
+
found = ", ".join(config.packages) or "none (edit the file to add them)"
|
|
55
|
+
click.echo(f"Created {CONFIG_PATH.as_posix()}. Packages: {found}")
|
doctracker/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Load and write the doctracker configuration (`doc/config/config.json`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DOC_DIR = "doc"
|
|
8
|
+
CONFIG_PATH = Path(DOC_DIR) / "config" / "config.json"
|
|
9
|
+
GITIGNORE_PATH = Path(DOC_DIR) / ".gitignore"
|
|
10
|
+
GITIGNORE_CONTENT = "sqlite/\nmodels/\n"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
"""Settings read from `doc/config/config.json`."""
|
|
16
|
+
|
|
17
|
+
packages: list[str] = field(default_factory=list)
|
|
18
|
+
search_paths: list[str] = field(default_factory=lambda: ["src", "."])
|
|
19
|
+
host: str = "127.0.0.1"
|
|
20
|
+
port: int = 8765
|
|
21
|
+
docstring_style: str = "google"
|
|
22
|
+
show_private: bool = True
|
|
23
|
+
code_theme: str = "default"
|
|
24
|
+
ssl_certfile: str | None = None
|
|
25
|
+
ssl_keyfile: str | None = None
|
|
26
|
+
watch_interval: float = 1.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config(project_root: Path) -> Config:
|
|
30
|
+
"""Read the config file of a project, filling missing keys with defaults."""
|
|
31
|
+
path = project_root / CONFIG_PATH
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
known = Config.__dataclass_fields__
|
|
34
|
+
return Config(**{key: value for key, value in data.items() if key in known})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_config(project_root: Path, config: Config) -> None:
|
|
38
|
+
"""Write the config file and the `doc/.gitignore`."""
|
|
39
|
+
path = project_root / CONFIG_PATH
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(json.dumps(asdict(config), indent=2) + "\n", encoding="utf-8")
|
|
42
|
+
(project_root / GITIGNORE_PATH).write_text(GITIGNORE_CONTENT, encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def update_config_value(project_root: Path, key: str, value) -> None:
|
|
46
|
+
"""Change one key in the config file, keeping everything else as it is on disk."""
|
|
47
|
+
path = project_root / CONFIG_PATH
|
|
48
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
data[key] = value
|
|
50
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_packages(project_root: Path, search_paths: list[str]) -> list[str]:
|
|
54
|
+
"""Find top-level Python packages (folders with `__init__.py`) in the search paths."""
|
|
55
|
+
packages = []
|
|
56
|
+
for search_path in search_paths:
|
|
57
|
+
base = project_root / search_path
|
|
58
|
+
if not base.is_dir():
|
|
59
|
+
continue
|
|
60
|
+
for child in sorted(base.iterdir()):
|
|
61
|
+
if child.name.startswith(".") or child.name == DOC_DIR:
|
|
62
|
+
continue
|
|
63
|
+
if (child / "__init__.py").is_file() and child.name not in packages:
|
|
64
|
+
packages.append(child.name)
|
|
65
|
+
return packages
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Read the technical structure of a project with griffe and turn it into plain data.
|
|
2
|
+
|
|
3
|
+
The result only holds built-in types (dicts, lists, strings), so it can cross
|
|
4
|
+
process boundaries and be stored later without depending on griffe objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import griffe
|
|
11
|
+
|
|
12
|
+
# Members griffe can report for each kind of object, in display order.
|
|
13
|
+
MEMBER_KINDS = ("module", "class", "function", "attribute", "type alias")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_project(
|
|
17
|
+
project_root: str,
|
|
18
|
+
packages: list[str],
|
|
19
|
+
search_paths: list[str],
|
|
20
|
+
docstring_style: str,
|
|
21
|
+
show_private: bool,
|
|
22
|
+
) -> dict:
|
|
23
|
+
"""Load every package and return `{"roots": [...], "entities": {path: entity}}`."""
|
|
24
|
+
logging.getLogger("griffe").setLevel(logging.ERROR)
|
|
25
|
+
root = Path(project_root)
|
|
26
|
+
paths = [str(root / path) for path in search_paths]
|
|
27
|
+
entities: dict[str, dict] = {}
|
|
28
|
+
roots = []
|
|
29
|
+
errors = []
|
|
30
|
+
for package in packages:
|
|
31
|
+
try:
|
|
32
|
+
module = griffe.load(
|
|
33
|
+
package,
|
|
34
|
+
search_paths=paths,
|
|
35
|
+
docstring_parser=docstring_style,
|
|
36
|
+
allow_inspection=False,
|
|
37
|
+
)
|
|
38
|
+
except (griffe.GriffeError, ImportError, OSError) as error:
|
|
39
|
+
errors.append(f"{package}: {error}")
|
|
40
|
+
continue
|
|
41
|
+
roots.append(module.path)
|
|
42
|
+
_collect(module, root, docstring_style, show_private, entities)
|
|
43
|
+
return {"roots": roots, "entities": entities, "errors": errors}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _collect(obj, root: Path, style: str, show_private: bool, entities: dict) -> None:
|
|
47
|
+
"""Add `obj` and its members (recursively) to `entities`."""
|
|
48
|
+
entity = _to_entity(obj, root, style)
|
|
49
|
+
members = []
|
|
50
|
+
for member in obj.members.values():
|
|
51
|
+
if member.is_alias:
|
|
52
|
+
continue
|
|
53
|
+
if member.is_private and not show_private:
|
|
54
|
+
continue
|
|
55
|
+
if member.kind.value not in MEMBER_KINDS:
|
|
56
|
+
continue
|
|
57
|
+
members.append(member.path)
|
|
58
|
+
_collect(member, root, style, show_private, entities)
|
|
59
|
+
entity["members"] = members
|
|
60
|
+
entities[obj.path] = entity
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_entity(obj, root: Path, style: str) -> dict:
|
|
64
|
+
"""Convert one griffe object to a plain dict."""
|
|
65
|
+
entity = {
|
|
66
|
+
"path": obj.path,
|
|
67
|
+
"name": obj.name,
|
|
68
|
+
"kind": obj.kind.value,
|
|
69
|
+
"parent": obj.parent.path if obj.parent else None,
|
|
70
|
+
"filepath": _relative(obj.filepath, root),
|
|
71
|
+
"lineno": obj.lineno,
|
|
72
|
+
"endlineno": obj.endlineno,
|
|
73
|
+
"is_private": obj.is_private,
|
|
74
|
+
"docstring": obj.docstring.value if obj.docstring else "",
|
|
75
|
+
"sections": _sections(obj.docstring, style),
|
|
76
|
+
"decorators": [str(decorator.value) for decorator in getattr(obj, "decorators", [])],
|
|
77
|
+
}
|
|
78
|
+
if obj.is_function:
|
|
79
|
+
entity["parameters"] = [_parameter(parameter) for parameter in obj.parameters]
|
|
80
|
+
entity["returns"] = str(obj.returns) if obj.returns else ""
|
|
81
|
+
if obj.is_class:
|
|
82
|
+
entity["bases"] = [str(base) for base in obj.bases]
|
|
83
|
+
init = obj.members.get("__init__")
|
|
84
|
+
params = init.parameters if init is not None and init.is_function else []
|
|
85
|
+
entity["parameters"] = [_parameter(parameter) for parameter in params if parameter.name != "self"]
|
|
86
|
+
if obj.is_attribute:
|
|
87
|
+
entity["annotation"] = str(obj.annotation) if obj.annotation else ""
|
|
88
|
+
entity["value"] = str(obj.value) if obj.value is not None else ""
|
|
89
|
+
if obj.is_module:
|
|
90
|
+
entity["exports"] = [str(name) for name in obj.exports] if obj.exports else []
|
|
91
|
+
entity["is_package"] = entity["filepath"].endswith("__init__.py") or isinstance(obj.filepath, list)
|
|
92
|
+
return entity
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parameter(parameter) -> dict:
|
|
96
|
+
kind = parameter.kind.value
|
|
97
|
+
# griffe fills `*args`/`**kwargs` with `()`/`{}`, which are not real defaults.
|
|
98
|
+
variadic = kind in ("variadic positional", "variadic keyword")
|
|
99
|
+
return {
|
|
100
|
+
"name": parameter.name,
|
|
101
|
+
"annotation": str(parameter.annotation) if parameter.annotation else "",
|
|
102
|
+
"default": str(parameter.default) if parameter.default is not None and not variadic else "",
|
|
103
|
+
"kind": kind,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _sections(docstring, style: str) -> list[dict]:
|
|
108
|
+
"""Parse a docstring into a list of `{"kind", "title", "text", "items"}` sections."""
|
|
109
|
+
if docstring is None:
|
|
110
|
+
return []
|
|
111
|
+
sections = []
|
|
112
|
+
for section in docstring.parse(style):
|
|
113
|
+
value = section.value
|
|
114
|
+
entry = {"kind": section.kind.value, "title": section.title or "", "text": "", "items": []}
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
entry["text"] = value
|
|
117
|
+
elif isinstance(value, list):
|
|
118
|
+
for item in value:
|
|
119
|
+
if isinstance(item, tuple): # examples: (kind, text), kind is "text" or "examples" (code)
|
|
120
|
+
entry["items"].append(
|
|
121
|
+
{"name": "", "annotation": "", "description": item[1], "is_code": item[0].value == "examples"}
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
entry["items"].append(_section_item(item))
|
|
125
|
+
else: # admonition, deprecated
|
|
126
|
+
entry["text"] = getattr(value, "description", "") or ""
|
|
127
|
+
entry["title"] = entry["title"] or str(getattr(value, "annotation", "") or "")
|
|
128
|
+
sections.append(entry)
|
|
129
|
+
return sections
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _section_item(item) -> dict:
|
|
133
|
+
annotation = getattr(item, "annotation", None)
|
|
134
|
+
return {
|
|
135
|
+
"name": getattr(item, "name", "") or "",
|
|
136
|
+
"annotation": str(annotation) if annotation else "",
|
|
137
|
+
"description": getattr(item, "description", "") or "",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _relative(filepath, root: Path) -> str:
|
|
142
|
+
"""Path of the source file relative to the project root, with forward slashes."""
|
|
143
|
+
if isinstance(filepath, list): # namespace packages have several folders
|
|
144
|
+
filepath = filepath[0]
|
|
145
|
+
try:
|
|
146
|
+
return Path(filepath).relative_to(root).as_posix()
|
|
147
|
+
except ValueError:
|
|
148
|
+
return Path(filepath).as_posix()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server and the services it provides (frontend, watcher)."""
|
doctracker/server/app.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Build the doctracker server: MCP endpoint at `/mcp`, wiki frontend and watcher."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
from mcp.server import MCPServer
|
|
9
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
10
|
+
from starlette.applications import Starlette
|
|
11
|
+
|
|
12
|
+
from doctracker import __version__
|
|
13
|
+
from doctracker.config import Config
|
|
14
|
+
from doctracker.server.state import DocState
|
|
15
|
+
from doctracker.server.tools import register_tools
|
|
16
|
+
from doctracker.server.watcher import watch
|
|
17
|
+
from doctracker.server.web import web_routes
|
|
18
|
+
|
|
19
|
+
LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_app(project_root: Path, config: Config) -> Starlette:
|
|
23
|
+
"""Create the ASGI app with every service the server provides."""
|
|
24
|
+
state = DocState()
|
|
25
|
+
mcp = MCPServer(
|
|
26
|
+
name="doctracker",
|
|
27
|
+
version=__version__,
|
|
28
|
+
instructions=(
|
|
29
|
+
"Technical documentation of this project, built from its code. "
|
|
30
|
+
"Use list_entities to explore and get_entity for details. "
|
|
31
|
+
"Before writing or changing docstrings, call documentation_guide (also available as the "
|
|
32
|
+
"doctracker://guide resource) and follow it."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
register_tools(mcp, state, config)
|
|
36
|
+
|
|
37
|
+
app = mcp.streamable_http_app(host=config.host, transport_security=_transport_security(config))
|
|
38
|
+
app.router.routes.extend(web_routes(state, project_root, config))
|
|
39
|
+
|
|
40
|
+
mcp_lifespan = app.router.lifespan_context
|
|
41
|
+
|
|
42
|
+
@contextlib.asynccontextmanager
|
|
43
|
+
async def lifespan(starlette_app: Starlette):
|
|
44
|
+
async with mcp_lifespan(starlette_app) as lifespan_state:
|
|
45
|
+
watcher = asyncio.create_task(watch(project_root, config, state))
|
|
46
|
+
try:
|
|
47
|
+
yield lifespan_state
|
|
48
|
+
finally:
|
|
49
|
+
watcher.cancel()
|
|
50
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
51
|
+
await watcher
|
|
52
|
+
|
|
53
|
+
app.router.lifespan_context = lifespan
|
|
54
|
+
return app
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _transport_security(config: Config) -> TransportSecuritySettings | None:
|
|
58
|
+
"""Host/Origin validation. The SDK already protects local hosts; other hosts are allowed explicitly."""
|
|
59
|
+
if config.host in LOCAL_HOSTS:
|
|
60
|
+
return None
|
|
61
|
+
scheme = "https" if config.ssl_certfile else "http"
|
|
62
|
+
return TransportSecuritySettings(
|
|
63
|
+
enable_dns_rebinding_protection=True,
|
|
64
|
+
allowed_hosts=[f"{config.host}:*"],
|
|
65
|
+
allowed_origins=[f"{scheme}://{config.host}:*"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(project_root: Path, config: Config) -> None:
|
|
70
|
+
"""Start the server and block until it stops."""
|
|
71
|
+
uvicorn.run(
|
|
72
|
+
create_app(project_root, config),
|
|
73
|
+
host=config.host,
|
|
74
|
+
port=config.port,
|
|
75
|
+
ssl_certfile=config.ssl_certfile,
|
|
76
|
+
ssl_keyfile=config.ssl_keyfile,
|
|
77
|
+
log_level="info",
|
|
78
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# How to document code for doctracker
|
|
2
|
+
|
|
3
|
+
doctracker builds the technical reference of this project by reading the code
|
|
4
|
+
statically (it never imports or runs it). Everything shown in the wiki comes from
|
|
5
|
+
the code itself: names, signatures, type hints, decorators and docstrings. Follow
|
|
6
|
+
these rules so every entity gets a complete page.
|
|
7
|
+
|
|
8
|
+
## Settings of this project
|
|
9
|
+
|
|
10
|
+
- Docstring style: **{docstring_style}** (`docstring_style` in `doc/config/config.json`).
|
|
11
|
+
- Private members (`_name`): **{private_members}** (`show_private`).
|
|
12
|
+
|
|
13
|
+
## What to document
|
|
14
|
+
|
|
15
|
+
1. **Every module and package** starts with a module docstring: one summary line,
|
|
16
|
+
then a short paragraph saying what the module is for. A package's docstring goes
|
|
17
|
+
in its `__init__.py`.
|
|
18
|
+
2. **Every public class, function and method** has a docstring.
|
|
19
|
+
3. **Type hints on every parameter and return value.** The wiki shows them in the
|
|
20
|
+
signature and in the parameters table; do not repeat types inside the docstring
|
|
21
|
+
unless the hint is missing.
|
|
22
|
+
4. **Module-level constants and class attributes** get a docstring written as a
|
|
23
|
+
string literal on the line right after the assignment:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
DEFAULT_UNITS: Units = Units.METRIC
|
|
27
|
+
"""Units used when the caller does not choose any."""
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Instance attributes set in `__init__` (and dataclass fields) are documented in
|
|
31
|
+
the class docstring's `Attributes` section instead.
|
|
32
|
+
5. **Public API of a package:** list it in `__all__`; the wiki shows it on the
|
|
33
|
+
package page.
|
|
34
|
+
|
|
35
|
+
## Docstring layout
|
|
36
|
+
|
|
37
|
+
- First line: one sentence, imperative mood for functions ("Fetch the forecast…"),
|
|
38
|
+
ending with a period. It is used as the summary in lists and search results.
|
|
39
|
+
- Blank line, then any longer explanation (Markdown is rendered).
|
|
40
|
+
- Then the sections below, in this order. Leave out the sections that do not apply.
|
|
41
|
+
|
|
42
|
+
| Section | When |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Args | every parameter, including `*args` and `**kwargs` |
|
|
45
|
+
| Returns / Yields | what is returned or yielded, and what it means |
|
|
46
|
+
| Raises | every exception the caller should expect, and when |
|
|
47
|
+
| Attributes | instance attributes and dataclass fields (class docstrings) |
|
|
48
|
+
| Examples | short `>>>` sessions; they are shown as code |
|
|
49
|
+
| Note / Warning | anything the caller must not miss |
|
|
50
|
+
|
|
51
|
+
## Example in the {docstring_style} style
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
{example}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Things to avoid
|
|
58
|
+
|
|
59
|
+
- Docstrings that only repeat the name ("Get weather." for `get_weather`).
|
|
60
|
+
- Describing the implementation instead of the behavior; the wiki already shows the source.
|
|
61
|
+
- Parameters missing from `Args`: the table shows "-" in their description.
|
|
62
|
+
- Raw HTML in docstrings: it is escaped, not rendered.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The documentation guide agents read through the MCP server."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from doctracker.config import Config
|
|
6
|
+
|
|
7
|
+
EXAMPLES = {
|
|
8
|
+
"google": '''def get_weather(city: str, units: str = "metric") -> Forecast:
|
|
9
|
+
"""Fetch the forecast for a city.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
city: City name, e.g. `Lisbon`.
|
|
13
|
+
units: `metric` or `imperial`.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Forecast data for the city.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
CityNotFound: When the city is unknown.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> get_weather("Lisbon").temperature
|
|
23
|
+
21.5
|
|
24
|
+
"""''',
|
|
25
|
+
"numpy": '''def get_weather(city: str, units: str = "metric") -> Forecast:
|
|
26
|
+
"""Fetch the forecast for a city.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
city
|
|
31
|
+
City name, e.g. `Lisbon`.
|
|
32
|
+
units
|
|
33
|
+
`metric` or `imperial`.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
Forecast
|
|
38
|
+
Forecast data for the city.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
CityNotFound
|
|
43
|
+
When the city is unknown.
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
--------
|
|
47
|
+
>>> get_weather("Lisbon").temperature
|
|
48
|
+
21.5
|
|
49
|
+
"""''',
|
|
50
|
+
"sphinx": '''def get_weather(city: str, units: str = "metric") -> Forecast:
|
|
51
|
+
"""Fetch the forecast for a city.
|
|
52
|
+
|
|
53
|
+
:param city: City name, e.g. `Lisbon`.
|
|
54
|
+
:param units: `metric` or `imperial`.
|
|
55
|
+
:return: Forecast data for the city.
|
|
56
|
+
:raises CityNotFound: When the city is unknown.
|
|
57
|
+
"""''',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def documentation_guide(config: Config) -> str:
|
|
62
|
+
"""The guide in Markdown, filled in with this project's settings."""
|
|
63
|
+
template = files("doctracker.server").joinpath("guide.md").read_text(encoding="utf-8")
|
|
64
|
+
return template.format(
|
|
65
|
+
docstring_style=config.docstring_style,
|
|
66
|
+
private_members="shown in the wiki" if config.show_private else "hidden from the wiki",
|
|
67
|
+
example=EXAMPLES.get(config.docstring_style, EXAMPLES["google"]),
|
|
68
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""In-memory documentation data shared by the MCP tools and the frontend."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DocState:
|
|
9
|
+
"""Latest extraction result plus the open frontend connections to notify."""
|
|
10
|
+
|
|
11
|
+
roots: list[str] = field(default_factory=list)
|
|
12
|
+
entities: dict[str, dict] = field(default_factory=dict)
|
|
13
|
+
errors: list[str] = field(default_factory=list)
|
|
14
|
+
ready: bool = False
|
|
15
|
+
listeners: set[asyncio.Queue] = field(default_factory=set)
|
|
16
|
+
|
|
17
|
+
def update(self, result: dict) -> None:
|
|
18
|
+
"""Replace the data with a new extraction result and notify the frontend."""
|
|
19
|
+
self.roots = result["roots"]
|
|
20
|
+
self.entities = result["entities"]
|
|
21
|
+
self.errors = result["errors"]
|
|
22
|
+
self.ready = True
|
|
23
|
+
for queue in self.listeners:
|
|
24
|
+
queue.put_nowait("changed")
|
|
25
|
+
|
|
26
|
+
def subscribe(self) -> asyncio.Queue:
|
|
27
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
28
|
+
self.listeners.add(queue)
|
|
29
|
+
return queue
|
|
30
|
+
|
|
31
|
+
def unsubscribe(self, queue: asyncio.Queue) -> None:
|
|
32
|
+
self.listeners.discard(queue)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""MCP tools that let agents read the technical documentation."""
|
|
2
|
+
|
|
3
|
+
from mcp.server import MCPServer
|
|
4
|
+
|
|
5
|
+
from doctracker.config import Config
|
|
6
|
+
from doctracker.server.guide import documentation_guide as build_guide
|
|
7
|
+
from doctracker.server.state import DocState
|
|
8
|
+
|
|
9
|
+
GUIDE_URI = "doctracker://guide"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_tools(mcp: MCPServer, state: DocState, config: Config) -> None:
|
|
13
|
+
"""Add the documentation tools and the documentation guide to the MCP server."""
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
def documentation_guide() -> str:
|
|
17
|
+
"""Explain how to document this project's code so doctracker builds complete pages.
|
|
18
|
+
|
|
19
|
+
Read it before writing or changing docstrings, module docstrings, type hints or `__all__`.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The guide in Markdown, adapted to this project's settings (docstring style, private members).
|
|
23
|
+
"""
|
|
24
|
+
return build_guide(config)
|
|
25
|
+
|
|
26
|
+
@mcp.resource(GUIDE_URI, name="documentation-guide", title="How to document code for doctracker",
|
|
27
|
+
mime_type="text/markdown")
|
|
28
|
+
def guide_resource() -> str:
|
|
29
|
+
"""How to document this project's code so doctracker builds complete pages."""
|
|
30
|
+
return build_guide(config)
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def list_entities(prefix: str = "", kind: str = "") -> list[dict]:
|
|
34
|
+
"""List documented entities.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
prefix: Only entities whose qualified path starts with this text (e.g. `mypkg.api`).
|
|
38
|
+
kind: Only entities of this kind: `module`, `class`, `function`, `attribute` or `type alias`.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
One item per entity with its path, kind and the first line of its docstring.
|
|
42
|
+
"""
|
|
43
|
+
items = []
|
|
44
|
+
for path, entity in sorted(state.entities.items()):
|
|
45
|
+
if prefix and not path.startswith(prefix):
|
|
46
|
+
continue
|
|
47
|
+
if kind and entity["kind"] != kind:
|
|
48
|
+
continue
|
|
49
|
+
summary = entity["docstring"].strip().split("\n", 1)[0] if entity["docstring"] else ""
|
|
50
|
+
items.append({"path": path, "kind": entity["kind"], "summary": summary})
|
|
51
|
+
return items
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def get_entity(path: str) -> dict:
|
|
55
|
+
"""Get the full technical reference of one entity.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: Qualified path of the entity (e.g. `mypkg.api.get_weather`).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Signature data, parameters, docstring sections, location in the code and members.
|
|
62
|
+
"""
|
|
63
|
+
entity = state.entities.get(path)
|
|
64
|
+
if entity is None:
|
|
65
|
+
raise ValueError(f"Unknown entity: {path}")
|
|
66
|
+
return entity
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def status() -> dict:
|
|
70
|
+
"""Report whether the documentation is built and any loading errors."""
|
|
71
|
+
return {
|
|
72
|
+
"ready": state.ready,
|
|
73
|
+
"packages": state.roots,
|
|
74
|
+
"entities": len(state.entities),
|
|
75
|
+
"errors": state.errors,
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Watch the project's Python files and rebuild the documentation when they change.
|
|
2
|
+
|
|
3
|
+
Change detection polls file modification times (standard library only). The
|
|
4
|
+
extraction itself runs in a separate process so the server stays responsive.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from doctracker.config import DOC_DIR, Config
|
|
12
|
+
from doctracker.extraction import extract_project
|
|
13
|
+
from doctracker.server.state import DocState
|
|
14
|
+
|
|
15
|
+
SKIPPED_DIRS = {DOC_DIR, ".git", ".venv", "venv", "__pycache__", "node_modules", ".mypy_cache", ".ruff_cache"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def snapshot(project_root: Path, search_paths: list[str]) -> dict[str, int]:
|
|
19
|
+
"""Map every watched `.py` file to its modification time."""
|
|
20
|
+
files = {}
|
|
21
|
+
for search_path in search_paths:
|
|
22
|
+
base = project_root / search_path
|
|
23
|
+
if base.is_dir():
|
|
24
|
+
_scan(base, files)
|
|
25
|
+
return files
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _scan(folder: Path, files: dict[str, int]) -> None:
|
|
29
|
+
try:
|
|
30
|
+
entries = list(folder.iterdir())
|
|
31
|
+
except OSError:
|
|
32
|
+
return
|
|
33
|
+
for entry in entries:
|
|
34
|
+
if entry.is_dir():
|
|
35
|
+
if entry.name not in SKIPPED_DIRS and not entry.name.startswith("."):
|
|
36
|
+
_scan(entry, files)
|
|
37
|
+
elif entry.suffix == ".py":
|
|
38
|
+
try:
|
|
39
|
+
files[str(entry)] = entry.stat().st_mtime_ns
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def watch(project_root: Path, config: Config, state: DocState) -> None:
|
|
45
|
+
"""Build the documentation once, then rebuild it whenever a watched file changes."""
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
with ProcessPoolExecutor(max_workers=1) as executor:
|
|
48
|
+
previous = None
|
|
49
|
+
while True:
|
|
50
|
+
current = await loop.run_in_executor(executor, snapshot, project_root, config.search_paths)
|
|
51
|
+
if current != previous:
|
|
52
|
+
result = await loop.run_in_executor(
|
|
53
|
+
executor,
|
|
54
|
+
extract_project,
|
|
55
|
+
str(project_root),
|
|
56
|
+
config.packages,
|
|
57
|
+
config.search_paths,
|
|
58
|
+
config.docstring_style,
|
|
59
|
+
config.show_private,
|
|
60
|
+
)
|
|
61
|
+
state.update(result)
|
|
62
|
+
previous = current
|
|
63
|
+
await asyncio.sleep(config.watch_interval)
|