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.
Files changed (38) hide show
  1. doctracker/__init__.py +5 -0
  2. doctracker/__main__.py +4 -0
  3. doctracker/cli.py +55 -0
  4. doctracker/config.py +65 -0
  5. doctracker/extraction/__init__.py +5 -0
  6. doctracker/extraction/griffe_loader.py +148 -0
  7. doctracker/server/__init__.py +1 -0
  8. doctracker/server/app.py +78 -0
  9. doctracker/server/guide.md +62 -0
  10. doctracker/server/guide.py +68 -0
  11. doctracker/server/state.py +32 -0
  12. doctracker/server/tools.py +76 -0
  13. doctracker/server/watcher.py +63 -0
  14. doctracker/server/web.py +120 -0
  15. doctracker/wiki/__init__.py +1 -0
  16. doctracker/wiki/pages.py +356 -0
  17. doctracker/wiki/static/htmx-LICENSE.txt +13 -0
  18. doctracker/wiki/static/htmx.min.js +1 -0
  19. doctracker/wiki/static/live.js +178 -0
  20. doctracker/wiki/static/style.css +424 -0
  21. doctracker/wiki/templates/base.html +33 -0
  22. doctracker/wiki/templates/entity.html +97 -0
  23. doctracker/wiki/templates/fragment.html +3 -0
  24. doctracker/wiki/templates/fragment_content.html +5 -0
  25. doctracker/wiki/templates/header.html +20 -0
  26. doctracker/wiki/templates/loading.html +2 -0
  27. doctracker/wiki/templates/members.html +18 -0
  28. doctracker/wiki/templates/module.html +19 -0
  29. doctracker/wiki/templates/not_found.html +6 -0
  30. doctracker/wiki/templates/search.html +21 -0
  31. doctracker/wiki/templates/source.html +1 -0
  32. doctracker/wiki/templates/tree.html +31 -0
  33. doctracker-0.1.0.dist-info/METADATA +234 -0
  34. doctracker-0.1.0.dist-info/RECORD +38 -0
  35. doctracker-0.1.0.dist-info/WHEEL +4 -0
  36. doctracker-0.1.0.dist-info/entry_points.txt +3 -0
  37. doctracker-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. doctracker-0.1.0.dist-info/licenses/src/doctracker/wiki/static/htmx-LICENSE.txt +13 -0
doctracker/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """doctracker: centralized documentation for Python projects developed with LLM agents."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("doctracker")
doctracker/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from doctracker.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,5 @@
1
+ """Extraction of technical data from the project's code."""
2
+
3
+ from doctracker.extraction.griffe_loader import extract_project
4
+
5
+ __all__ = ["extract_project"]
@@ -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)."""
@@ -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)