diff --git a/README.md b/README.md index 50f2a4a..7a223cd 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ pip install docbuddy docbuddy ``` -This starts a local server on port **8008** and opens your browser to [http://localhost:8008/docs/index.html](http://localhost:8008/docs/index.html). +This starts a local server on port **8008** and opens your browser to `http://localhost:8008/standalone.html`. #### Custom Host/Port diff --git a/docs/index.html b/docs/index.html index f7830b6..2665a26 100644 --- a/docs/index.html +++ b/docs/index.html @@ -448,7 +448,7 @@

πŸš€ Run Locally with CLI

Click to copy

- Opens at http://localhost:8008/docs/index.html + Opens at the address shown in the terminal (default port 8008)

Python Plugin

diff --git a/src/docbuddy/cli.py b/src/docbuddy/cli.py index ff5e9e5..0cb2f8e 100644 --- a/src/docbuddy/cli.py +++ b/src/docbuddy/cli.py @@ -2,11 +2,12 @@ """CLI entry point for launching DocBuddy standalone webpage.""" import argparse +import functools import http.server -import os import sys +import threading +import time import webbrowser - from importlib.resources import files @@ -33,36 +34,30 @@ def main(): args = parser.parse_args() - # Use importlib.resources to find the docs directory - docbuddy_pkg = files("docbuddy") - - # The docs directory is at the project root, one level up from src/docbuddy - # When installed, it's in site-packages/docbuddy/../.. - docs_path = docbuddy_pkg.parent.parent / "docs" + # Locate packaged assets via importlib.resources (works for both editable + # and normal pip installs; no os.chdir() needed). + pkg_ref = files("docbuddy") + standalone_ref = pkg_ref.joinpath("standalone.html") - if not docs_path.exists(): - print(f"Error: Could not find 'docs' directory at {docs_path}", file=sys.stderr) + if not standalone_ref.is_file(): + print( + f"Error: Could not find 'standalone.html' in the docbuddy package ({pkg_ref})", + file=sys.stderr, + ) sys.exit(1) - # Serve from the project root (parent of docs) so static files are accessible - # The index.html uses paths like /src/docbuddy/static/core.js which need the parent - os.chdir(docs_path.parent) + # Serve only the package directory – not the whole repo/site-packages root. + pkg_dir = str(pkg_ref) + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=pkg_dir) - url = f"http://{args.host}:{args.port}/docs/index.html" + url = f"http://{args.host}:{args.port}/standalone.html" print(f"Serving DocBuddy at {url}") print("Press Ctrl+C to stop the server") - # Start HTTP server - with http.server.HTTPServer( - (args.host, args.port), http.server.SimpleHTTPRequestHandler - ) as httpd: - # Give it a moment for server to fully start before opening browser - import threading + with http.server.HTTPServer((args.host, args.port), handler) as httpd: def open_browser(): - import time - time.sleep(0.5) webbrowser.open(url) diff --git a/src/docbuddy/standalone.html b/src/docbuddy/standalone.html new file mode 100644 index 0000000..07b05db --- /dev/null +++ b/src/docbuddy/standalone.html @@ -0,0 +1,765 @@ + + + + DocBuddy β€” AI-Enhanced API Documentation + + + + + + + + + + + +
+
+

DocBuddy

+

AI-Enhanced API Documentation

+

+ Load any OpenAPI schema and explore it with Chat, Workflow, and Agent + assistance β€” powered by your local LLM. +

+ + +
+ + +
+

+ Enter a direct openapi.json URL, or paste a /docs page URL + and DocBuddy will auto-detect the schema. +

+

+ +
+ + + +
+

πŸš€ Run Locally with CLI

+

+ Launch this page locally in order to connect to local LLMs (Ollama, LM Studio, vLLM). +

+
+ pip install docbuddy + Click to copy +
+
+ docbuddy + Click to copy +
+

+ Opens at + +

+ +

Python Plugin

+

+ Replace your FastAPI /docs page with DocBuddy β€” get AI chat, workflows, + and agent tools directly alongside your API explorer. +

+
from fastapi import FastAPI +from docbuddy import setup_docs + +app = FastAPI() +setup_docs(app) # replaces default /docs
+ +
+
+
+ + +
+
+ πŸ€– DocBuddy + +
+ +
+ + +
+ + + + + + + + + + + diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 9d2b7f4..db4deeb 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1796,3 +1796,96 @@ def test_standalone_page_has_docbuddy_plugin(): html = (Path(__file__).parent.parent / "docs" / "index.html").read_text() assert "DocBuddyPlugin" in html assert "LLMDocsLayout" in html + + +# ── CLI tests ────────────────────────────────────────────────────────────────── + + +def test_cli_standalone_html_is_packaged(): + """Verify standalone.html is shipped inside the docbuddy package.""" + from importlib.resources import files + + pkg_ref = files("docbuddy") + standalone_ref = pkg_ref.joinpath("standalone.html") + assert standalone_ref.is_file(), "standalone.html must be present in the installed package" + + +def test_cli_standalone_html_uses_local_static_path(): + """standalone.html must load JS from ./static (not from the repo root path).""" + from importlib.resources import files + + pkg_ref = files("docbuddy") + html = pkg_ref.joinpath("standalone.html").read_text(encoding="utf-8") + assert "./static" in html, "standalone.html should reference './static' for local assets" + assert "../src/docbuddy/static" not in html, ( + "standalone.html must not reference the repo-layout path ../src/docbuddy/static" + ) + + +def test_cli_main_exits_on_missing_standalone(monkeypatch): + """main() must exit with a clear message when standalone.html is missing.""" + import sys + from unittest.mock import MagicMock + + import pytest + + import docbuddy.cli as cli_module + + # Patch `files` as it is imported in cli.py (must patch the name in that module) + fake_ref = MagicMock() + fake_ref.__str__ = lambda _: "/fake/pkg" + fake_file = MagicMock() + fake_file.is_file.return_value = False + fake_ref.joinpath.return_value = fake_file + + monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + monkeypatch.setattr(sys, "argv", ["docbuddy"]) + + with pytest.raises(SystemExit) as exc_info: + cli_module.main() + assert exc_info.value.code == 1 + + +def test_cli_uses_directory_not_chdir(monkeypatch): + """main() must not call os.chdir; it must pass directory= to the HTTP handler.""" + import functools + import sys + from unittest.mock import MagicMock, patch + + import docbuddy.cli as cli_module + + # Stub out standalone.html lookup so it succeeds + fake_ref = MagicMock() + fake_ref.__str__ = lambda _: "/fake/pkg" + fake_file = MagicMock() + fake_file.is_file.return_value = True + fake_ref.joinpath.return_value = fake_file + + monkeypatch.setattr(cli_module, "files", lambda _pkg: fake_ref) + monkeypatch.setattr(sys, "argv", ["docbuddy"]) + + # Capture the handler passed to HTTPServer to verify directory= is set + captured_handler = {} + + def fake_http_server(addr, handler): + captured_handler["handler"] = handler + mock_httpd = MagicMock() + mock_httpd.__enter__ = lambda s: s + mock_httpd.__exit__ = MagicMock(return_value=False) + mock_httpd.serve_forever.side_effect = KeyboardInterrupt + return mock_httpd + + with ( + patch("http.server.HTTPServer", side_effect=fake_http_server), + patch("webbrowser.open"), + ): + try: + cli_module.main() + except SystemExit: + pass + + handler = captured_handler.get("handler") + assert handler is not None, "HTTPServer must be called with a handler" + # The handler must be a functools.partial with directory= keyword set + assert isinstance(handler, functools.partial), "handler must be a functools.partial" + assert "directory" in handler.keywords, "handler must have directory= keyword argument"