mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Merge pull request #2803 from arc53/feat/install-6-dev
feat: docsgpt dev, doctor and restart for the development loop
This commit is contained in:
9 files changed
+1417
-46
No files matched your search
Vendored
+24
-27
@@ -2,39 +2,36 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Frontend Debug (npm)",
|
||||
"name": "Frontend (npm)",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
{
|
||||
"name": "Flask Debugger",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"env": {
|
||||
"FLASK_APP": "docsgpt/app.py",
|
||||
"PYTHONPATH": "${workspaceFolder}",
|
||||
"FLASK_ENV": "development",
|
||||
"FLASK_DEBUG": "1",
|
||||
"FLASK_RUN_PORT": "7091",
|
||||
"FLASK_RUN_HOST": "0.0.0.0"
|
||||
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"--no-debugger"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "API (uvicorn)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}"
|
||||
},
|
||||
"args": [
|
||||
"docsgpt.asgi:asgi_app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"7091"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Celery Debugger",
|
||||
"name": "Celery worker",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}",
|
||||
"PYTHONPATH": "${workspaceFolder}"
|
||||
},
|
||||
"args": [
|
||||
"-A",
|
||||
@@ -47,10 +44,10 @@
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Dev Containers (Mongo + Redis)",
|
||||
"name": "Dev services (Postgres + Redis)",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "docker compose -f deployment/docker-compose-dev.yaml up --build",
|
||||
"command": "docker compose -f deployment/docker-compose-dev.yaml up",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
],
|
||||
@@ -58,9 +55,9 @@
|
||||
{
|
||||
"name": "DocsGPT: Full Stack",
|
||||
"configurations": [
|
||||
"Frontend Debug (npm)",
|
||||
"Flask Debugger",
|
||||
"Celery Debugger"
|
||||
"Frontend (npm)",
|
||||
"API (uvicorn)",
|
||||
"Celery worker"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "DocsGPT",
|
||||
@@ -68,4 +65,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,34 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
|
||||
|
||||
5. **Run the Backend:**
|
||||
|
||||
For local development, run the ASGI composition under uvicorn. It serves the **whole** application, hot-reloads on source changes, and matches the production runtime:
|
||||
One command runs the API and the worker from this checkout, each restarting when you save a file:
|
||||
|
||||
```bash
|
||||
docsgpt dev
|
||||
```
|
||||
|
||||
Both run as children of that terminal, with their output interleaved and labelled, and Ctrl-C stops
|
||||
them together. Useful flags:
|
||||
|
||||
| Flag | What it does |
|
||||
| --- | --- |
|
||||
| `--ui` | also start the Vite dev server, so the whole app runs from one command |
|
||||
| `--mock-llm` | run `scripts/mock_llm.py` and point DocsGPT at it, so no API key is needed |
|
||||
| `--no-worker` | leave the worker to you, for instance when debugging it in your editor |
|
||||
| `--no-reload` | do not restart anything on save |
|
||||
| `--port` | serve the API somewhere other than 7091 |
|
||||
|
||||
`docsgpt dev` is for a checkout. `docsgpt up --native`, by contrast, installs supervised services
|
||||
that outlive the shell — see [Run it as services](/Deploying/Pip-Install#run-it-as-services-without-docker).
|
||||
|
||||
<Callout type="info">
|
||||
`docsgpt doctor` checks the things that usually break a new setup: whether PostgreSQL answers and
|
||||
its schema matches this version, whether Redis answers, whether a model provider is configured,
|
||||
and whether the port is free. Run it first when something does not start.
|
||||
</Callout>
|
||||
|
||||
To run the two processes yourself instead, start the ASGI composition under uvicorn. It serves the
|
||||
**whole** application, hot-reloads on source changes, and matches the production runtime:
|
||||
|
||||
```bash
|
||||
uvicorn docsgpt.asgi:asgi_app --host 0.0.0.0 --port 7091 --reload
|
||||
@@ -135,7 +162,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
|
||||
|
||||
But it serves **only** the WSGI Flask app and omits the native-async routes mounted on the ASGI shell in `docsgpt/asgi.py`: the `/mcp` FastMCP endpoint, the chat reconnect reader `GET /api/messages/<id>/events`, the notification stream `GET /api/events`, the remote-device command stream `GET /api/devices/sessions/<id>/events`, and artifact downloads `GET /api/artifacts/<id>/download`. Under `flask run` those paths return 404 — chat still works (`POST /stream` is a Flask route), but live notifications, stream auto-resume, paired devices and artifact downloads don't. Use `flask run` only when you don't need them.
|
||||
|
||||
6. **Start the Celery Worker:**
|
||||
6. **Start the Celery Worker** (not needed if you used `docsgpt dev`)**:**
|
||||
|
||||
Open a new terminal window (and activate your virtual environment if you used one). Start the Celery worker to handle background tasks:
|
||||
|
||||
@@ -153,10 +180,14 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
|
||||
|
||||
**Running in Debugger (VSCode):**
|
||||
|
||||
For easier debugging, you can launch the Flask app and Celery worker directly from VSCode's debugger.
|
||||
For easier debugging, you can launch the API and the Celery worker directly from VSCode's debugger.
|
||||
|
||||
* Press <kbd>Shift</kbd> + <kbd>Cmd</kbd> + <kbd>D</kbd> (macOS) or <kbd>Shift</kbd> + <kbd>Windows</kbd> + <kbd>D</kbd> (Windows) to open the Run and Debug view.
|
||||
* You should see configurations named "Flask" and "Celery". Select the desired configuration and click the "Start Debugging" button (green play icon).
|
||||
* You should see configurations named "API (uvicorn)" and "Celery worker", and a compound "DocsGPT: Full Stack" that starts them with the frontend. Select one and click the "Start Debugging" button (green play icon).
|
||||
|
||||
The API configuration runs the same ASGI app as production, so the routes mounted on the ASGI shell
|
||||
work under the debugger. It deliberately runs without `--reload`: the reloader restarts the server in
|
||||
a child process, which your breakpoints would not be attached to.
|
||||
|
||||
## 3. Start the Frontend
|
||||
|
||||
@@ -207,3 +238,16 @@ To run the DocsGPT frontend locally, you'll need Node.js and npm (Node Package M
|
||||
This command will start the Vite development server. The frontend application will typically be accessible at [http://localhost:5173/](http://localhost:5173/). The terminal will display the exact URL where the frontend is running.
|
||||
|
||||
With both the backend and frontend running, you should now have a fully functional DocsGPT development environment. You can access the application in your browser at [http://localhost:5173/](http://localhost:5173/) and start developing!
|
||||
|
||||
## Working on two branches at once
|
||||
|
||||
Each install keeps its own directory and its own services, so a second branch can run beside the
|
||||
first as long as it gets its own port:
|
||||
|
||||
```bash
|
||||
docsgpt dev --port 7092 # a second checkout, second terminal
|
||||
docsgpt up --native --dir ~/.docsgpt/review --port 7092 # or a second installed copy
|
||||
```
|
||||
|
||||
A native install in another directory gets its own service names, so the two never write over each
|
||||
other's units. `docsgpt status --dir ~/.docsgpt/review` reports on that one alone.
|
||||
@@ -13,6 +13,17 @@ request, and [Upgrading](/upgrading) covers the steps an existing deployment has
|
||||
|
||||
## Unreleased
|
||||
|
||||
### A development loop in one command
|
||||
|
||||
`docsgpt dev` runs this checkout's API and worker as children of one terminal, both restarting when
|
||||
you save, with their output interleaved and Ctrl-C stopping them together. `--ui` adds the Vite dev
|
||||
server and `--mock-llm` runs the bundled mock model, so a working loop needs no API key.
|
||||
`docsgpt doctor` checks PostgreSQL, its schema version, Redis, the model provider and the port;
|
||||
`docsgpt restart` bounces the services without touching settings; `docsgpt logs -f` now follows a
|
||||
native install; and `docsgpt env set` applies itself to a running native install instead of asking
|
||||
you to run `docsgpt up` again. See
|
||||
[Setting up a development environment](/Deploying/Development-Environment).
|
||||
|
||||
### Run DocsGPT without Docker
|
||||
|
||||
`docsgpt up --native` runs the API and the worker as services on the machine itself, launchd on
|
||||
|
||||
+33
-2
@@ -89,7 +89,17 @@ def _api(args: argparse.Namespace) -> int:
|
||||
if args.reload or sys.platform == "win32":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("docsgpt.asgi:asgi_app", host=args.host, port=args.port, reload=args.reload)
|
||||
from docsgpt.core.paths import package_dir
|
||||
|
||||
# Watch the package, not the working directory: a checkout also holds .venv, node_modules
|
||||
# and the data the app writes (indexes/, inputs/), which restarts the server mid-ingest.
|
||||
uvicorn.run(
|
||||
"docsgpt.asgi:asgi_app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.reload,
|
||||
reload_dirs=[str(package_dir())] if args.reload else None,
|
||||
)
|
||||
return 0
|
||||
|
||||
_gunicorn_application(_gunicorn_options(args.host, args.port, args.workers)).run()
|
||||
@@ -246,12 +256,33 @@ def _add_deploy_commands(commands) -> None:
|
||||
restore.add_argument("--timeout", type=int, default=300,
|
||||
help="seconds to wait for the API afterwards (default: 300)")
|
||||
|
||||
doctor = stack_command("doctor", "doctor", "check what this machine needs to run DocsGPT")
|
||||
doctor.add_argument("--postgres-uri", help="check this database instead of the one in .env")
|
||||
doctor.add_argument("--redis-url", help="check this Redis instead of the one in .env")
|
||||
|
||||
restart = stack_command("restart", "restart", "restart the services, changing nothing else")
|
||||
restart.add_argument("services", nargs="*", help="services to restart, e.g. api worker")
|
||||
|
||||
env = stack_command("env", "env", "show, get or set the stack's settings")
|
||||
env_actions = env.add_subparsers(dest="env_action", metavar="<action>")
|
||||
get = env_actions.add_parser("get", help="print one setting")
|
||||
get.add_argument("key")
|
||||
set_ = env_actions.add_parser("set", help="set settings (KEY=VALUE ...); run `docsgpt up` to apply")
|
||||
set_ = env_actions.add_parser("set", help="set settings (KEY=VALUE ...)")
|
||||
set_.add_argument("pairs", nargs="+", metavar="KEY=VALUE")
|
||||
set_.add_argument("--no-restart", dest="restart", action="store_false",
|
||||
help="do not restart a running native install afterwards")
|
||||
|
||||
dev = commands.add_parser("dev", help="run this checkout's API, worker and UI with reload")
|
||||
dev.add_argument("--host", default=DEFAULT_HOST, help="interface for the API (default: localhost)")
|
||||
dev.add_argument("--port", type=int, default=DEFAULT_PORT, help="port for the API (default: 7091)")
|
||||
dev.add_argument("--ui", action="store_true", help="also run the frontend dev server")
|
||||
dev.add_argument("--mock-llm", action="store_true",
|
||||
help="run the mock LLM and point DocsGPT at it, so no API key is needed")
|
||||
dev.add_argument("--no-worker", dest="worker", action="store_false", help="do not run the Celery worker")
|
||||
dev.add_argument("--no-reload", dest="reload", action="store_false",
|
||||
help="do not restart the API and worker when a file changes")
|
||||
dev.add_argument("-l", "--loglevel", default="INFO", help="worker log level (default: INFO)")
|
||||
dev.set_defaults(func=_deploy("dev"), deploy=True)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
+320
-12
@@ -12,6 +12,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
@@ -214,30 +215,32 @@ def _redis_urls(base: str) -> dict[str, str]:
|
||||
parts = urlsplit(base)
|
||||
except ValueError as exc:
|
||||
# urlsplit raises on things like redis://[::1 ; that is a typo, not a crash.
|
||||
raise DeployError(f"the Redis URL {base!r} could not be read: {exc}") from exc
|
||||
raise DeployError(f"the Redis URL could not be read: {_scrub(str(exc), base)}") from exc
|
||||
try:
|
||||
port = parts.port # a non-numeric or out-of-range port raises here, not when the URL is split
|
||||
except ValueError as exc:
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} has an unusable port: {exc}. Pass a URL like "
|
||||
f"the Redis URL ({_endpoint(base)}) has an unusable port: {_scrub(str(exc), base)}. "
|
||||
"Pass a URL like "
|
||||
"redis://host:6379 or redis://host:6379/5."
|
||||
) from exc
|
||||
if port == 0:
|
||||
# urlsplit is happy with it, since 0 is inside the range, but nothing can connect to it.
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} has an unusable port: 0. Pass a URL like "
|
||||
f"the Redis URL ({_endpoint(base)}) has an unusable port: 0. Pass a URL like "
|
||||
"redis://host:6379 or redis://host:6379/5."
|
||||
)
|
||||
if parts.scheme not in ("redis", "rediss"):
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} should start with redis:// or rediss://, with any options as "
|
||||
f"the Redis URL ({_endpoint(base)}) should start with redis:// or rediss://, with any options as "
|
||||
"query parameters, so the broker, the result backend and the cache can be given a "
|
||||
"database each."
|
||||
)
|
||||
path = parts.path.rstrip("/").lstrip("/")
|
||||
if path and not (path.isascii() and path.isdigit()):
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} has {path!r} where a database number would go. Pass a URL like "
|
||||
f"the Redis URL ({_endpoint(base)}) has {path[:20]!r} where a database number would go. "
|
||||
"Pass a URL like "
|
||||
"redis://host:6379 or redis://host:6379/5."
|
||||
)
|
||||
try:
|
||||
@@ -246,7 +249,7 @@ def _redis_urls(base: str) -> dict[str, str]:
|
||||
# Python refuses to convert a digit string past its conversion limit, and that is a typo
|
||||
# rather than a crash.
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} has a database number too long to read. Pass a URL like "
|
||||
f"the Redis URL ({_endpoint(base)}) has a database number too long to read. Pass a URL like "
|
||||
"redis://host:6379 or redis://host:6379/5."
|
||||
) from exc
|
||||
return {
|
||||
@@ -614,21 +617,56 @@ def logs(args, context: Optional[Context] = None) -> int:
|
||||
if _mode(directory) == "native":
|
||||
logs_dir = directory / "logs"
|
||||
wanted = args.services or ["api", "worker"]
|
||||
# Opened before the first read and kept: a line written between printing what is there and
|
||||
# starting to follow would otherwise appear in neither.
|
||||
handles = {}
|
||||
for service in wanted:
|
||||
path = logs_dir / f"{service}.log"
|
||||
print(f"=== {path}")
|
||||
if path.is_file():
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
print("\n".join(lines[-args.tail:] if args.tail else lines))
|
||||
else:
|
||||
if not path.is_file():
|
||||
print("(nothing logged yet)")
|
||||
continue
|
||||
handle = path.open("r", encoding="utf-8", errors="replace")
|
||||
lines = handle.read().splitlines()
|
||||
print("\n".join(lines[-args.tail:] if args.tail else lines))
|
||||
handles[service] = handle
|
||||
if args.follow:
|
||||
print("Following is not supported in native mode; use `tail -f` on the files above.", file=sys.stderr)
|
||||
return _follow(logs_dir, wanted, handles)
|
||||
for handle in handles.values():
|
||||
handle.close()
|
||||
return 0
|
||||
options = (["--follow"] if args.follow else []) + (["--tail", str(args.tail)] if args.tail else [])
|
||||
return context.docker.compose(directory, "logs", *options, *args.services, check=False).returncode
|
||||
|
||||
|
||||
def _follow(logs_dir: Path, services: list, handles: Optional[dict] = None) -> int:
|
||||
"""Print new lines from each service's log until the terminal interrupts, prefixed by service.
|
||||
|
||||
``handles`` are the files already read, positioned where that read left off, so nothing written
|
||||
in between is skipped. Files that did not exist yet are opened as they appear.
|
||||
"""
|
||||
handles = dict(handles or {})
|
||||
try:
|
||||
while True:
|
||||
for service in services:
|
||||
if service not in handles:
|
||||
path = logs_dir / f"{service}.log"
|
||||
if not path.is_file():
|
||||
continue
|
||||
handle = path.open("r", encoding="utf-8", errors="replace")
|
||||
handle.seek(0, os.SEEK_END)
|
||||
handles[service] = handle
|
||||
for line in handles[service].readlines():
|
||||
print(f"{service:<6} | {line.rstrip()}")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.3)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
for handle in handles.values():
|
||||
handle.close()
|
||||
|
||||
|
||||
def token(args, context: Optional[Context] = None) -> int:
|
||||
"""Print the access token of a ``simple_jwt`` install."""
|
||||
directory = stack.stack_dir(args.dir)
|
||||
@@ -681,10 +719,280 @@ def env(args, context: Optional[Context] = None) -> int:
|
||||
envfile.update(env_path, updates)
|
||||
except ValueError as exc:
|
||||
raise DeployError(str(exc)) from exc
|
||||
print(f"Saved to {env_path}. Run `docsgpt up` to apply.")
|
||||
print(f"Saved to {env_path}.")
|
||||
directory = stack.stack_dir(args.dir)
|
||||
if _mode(directory) == "native" and getattr(args, "restart", True):
|
||||
context = context or Context.default(args)
|
||||
services = context.service_manager()
|
||||
names = _service_names(directory)
|
||||
if any(services.is_running(name) for name in names):
|
||||
for name in reversed(names):
|
||||
services.stop(name)
|
||||
for name in names:
|
||||
services.start(name)
|
||||
print("Restarted the services, so the change is live.")
|
||||
return 0
|
||||
print("Run `docsgpt up` to apply.")
|
||||
return 0
|
||||
|
||||
|
||||
def dev(args, context: Optional[Context] = None) -> int:
|
||||
"""Run this checkout's API, worker and UI as children of this terminal."""
|
||||
from docsgpt.core import paths
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
checkout = paths.checkout_root()
|
||||
if checkout is None:
|
||||
raise DeployError(
|
||||
"`docsgpt dev` runs the code in a source checkout, and this is an installed package. "
|
||||
"Clone the repository and run it from there, or use `docsgpt up --native` to run this copy."
|
||||
)
|
||||
port = _port_number(args.port, "--port")
|
||||
if not _port_is_free(port):
|
||||
raise DeployError(
|
||||
f"port {args.port} is already in use, so the API cannot bind it. Stop what is on it "
|
||||
f"(a previous `docsgpt dev`, or `docsgpt down` for an install), or pass --port."
|
||||
)
|
||||
if getattr(args, "mock_llm", False) and args.port == dev_module.MOCK_LLM_PORT:
|
||||
# Both checks below would pass: the port really is free, and then two children want it.
|
||||
raise DeployError(
|
||||
f"port {args.port} is where the mock LLM listens, so the API cannot have it too. "
|
||||
"Give the API another port with --port."
|
||||
)
|
||||
if getattr(args, "mock_llm", False) and not _port_is_free(dev_module.MOCK_LLM_PORT):
|
||||
# It starts first and the others are pointed at it, so a busy port here would surface as the
|
||||
# API talking to someone else's server, or as a child exiting once everything else is up.
|
||||
raise DeployError(
|
||||
f"port {dev_module.MOCK_LLM_PORT} is already in use, so the mock LLM cannot bind it. "
|
||||
"Stop what is on it, or leave --mock-llm off and point DocsGPT at a real provider."
|
||||
)
|
||||
children = dev_module.plan(args, checkout)
|
||||
print(f"DocsGPT from {checkout}")
|
||||
for child in children:
|
||||
print(f" {child.name:<6} {' '.join(child.command)}")
|
||||
print(f"\nAPI http://{args.host}:{args.port}")
|
||||
if getattr(args, "ui", False):
|
||||
print(f"UI http://localhost:{dev_module.UI_PORT}")
|
||||
print("Ctrl-C stops everything.\n")
|
||||
return dev_module.run(children)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
"""One line of ``docsgpt doctor``: what was looked at and what came back."""
|
||||
|
||||
name: str
|
||||
level: str
|
||||
detail: str
|
||||
|
||||
|
||||
MARKS = {"ok": "ok ", "warn": "warn", "fail": "FAIL"}
|
||||
|
||||
|
||||
def _migration_head() -> Optional[str]:
|
||||
"""The newest revision shipped with this package, or None when alembic cannot say."""
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
except ImportError:
|
||||
return None
|
||||
ini = Path(__file__).resolve().parents[1] / "alembic.ini"
|
||||
if not ini.is_file():
|
||||
return None
|
||||
config = Config(str(ini))
|
||||
config.set_main_option("script_location", str(ini.parent / "alembic"))
|
||||
try:
|
||||
return ScriptDirectory.from_config(config).get_current_head()
|
||||
except Exception: # noqa: BLE001 - a broken script directory is a doctor finding, not a crash
|
||||
return None
|
||||
|
||||
|
||||
def _check_postgres(uri: Optional[str]) -> Check:
|
||||
"""Connect, and say whether the schema is the one this version expects."""
|
||||
if not uri:
|
||||
return Check("postgres", "fail", "POSTGRES_URI is not set")
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
return Check("postgres", "fail", "the psycopg driver is not installed")
|
||||
try:
|
||||
with psycopg.connect(uri, connect_timeout=5) as connection, connection.cursor() as cursor:
|
||||
cursor.execute("select current_setting('server_version')")
|
||||
version = cursor.fetchone()[0]
|
||||
# Unqualified, like alembic itself: env.py sets no version_table_schema, so the table
|
||||
# lives wherever search_path puts it. Asserting public would call a migrated database empty.
|
||||
cursor.execute("select to_regclass('alembic_version')")
|
||||
applied = cursor.fetchone()[0] is not None
|
||||
current = None
|
||||
if applied:
|
||||
# The same relation the check above resolved, by the same rules.
|
||||
cursor.execute("select version_num from alembic_version")
|
||||
row = cursor.fetchone()
|
||||
current = row[0] if row else None
|
||||
except (psycopg.Error, OSError, ValueError) as exc:
|
||||
return Check("postgres", "fail", f"cannot connect to {_endpoint(uri)}: {_scrub(str(exc).strip(), uri)}")
|
||||
head = _migration_head()
|
||||
if not current:
|
||||
return Check("postgres", "fail", f"PostgreSQL {version}, no schema yet; run `docsgpt migrate`")
|
||||
if head and current != head:
|
||||
return Check("postgres", "fail", f"PostgreSQL {version} at {current}, this version wants {head}; "
|
||||
"run `docsgpt migrate`")
|
||||
return Check("postgres", "ok", f"PostgreSQL {version}, schema at {current}")
|
||||
|
||||
|
||||
def _endpoint(url: str) -> str:
|
||||
"""A URL without its credentials: this ends up on a terminal, in CI logs and in issues."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
# .port is a property that parses on access, so it raises separately from the split itself.
|
||||
host, port, scheme, path = parts.hostname or "", parts.port, parts.scheme, parts.path
|
||||
except ValueError:
|
||||
return "the configured URL"
|
||||
if port:
|
||||
host = f"{host}:{port}"
|
||||
# The path is capped: this string is for a person to read, and it goes into terminals, CI logs
|
||||
# and error messages. A 5000-digit database number would otherwise flood all three.
|
||||
if len(path) > 40:
|
||||
path = f"{path[:40]}..."
|
||||
return f"{scheme}://{host}{path}" if host else "the configured URL"
|
||||
|
||||
|
||||
def _scrub(text: str, url: Optional[str]) -> str:
|
||||
"""Client errors quote the URL they were handed, credentials and all, so take them back out."""
|
||||
if not url:
|
||||
return text
|
||||
text = text.replace(url, _endpoint(url))
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return text
|
||||
for secret in (parts.password, parts.username):
|
||||
if secret:
|
||||
text = text.replace(secret, "...")
|
||||
return text
|
||||
|
||||
|
||||
def _check_redis(urls: Mapping[str, str]) -> Check:
|
||||
"""Ping every Redis the settings name; they are usually one server, three databases."""
|
||||
if not urls:
|
||||
return Check("redis", "fail", "no Redis is configured (CELERY_BROKER_URL)")
|
||||
try:
|
||||
import redis
|
||||
except ImportError:
|
||||
return Check("redis", "fail", "the redis client is not installed")
|
||||
for label, url in sorted(urls.items()):
|
||||
try:
|
||||
redis.Redis.from_url(url, socket_connect_timeout=3).ping()
|
||||
except Exception as exc: # noqa: BLE001 - every client error here is the same finding
|
||||
return Check(
|
||||
"redis", "fail", f"{label} ({_endpoint(url)}) does not answer: {_scrub(str(exc).strip(), url)}"
|
||||
)
|
||||
return Check("redis", "ok", f"answering on {len(urls)} database(s)")
|
||||
|
||||
|
||||
def _check_provider(env: Mapping[str, str]) -> Check:
|
||||
"""Whether a model provider is set up well enough to answer a question."""
|
||||
provider = env.get("LLM_PROVIDER") or "docsgpt"
|
||||
if provider == "docsgpt":
|
||||
return Check("provider", "ok", "the DocsGPT public API (no key needed)")
|
||||
if not (env.get("API_KEY") or env.get("OPENAI_API_KEY")):
|
||||
return Check("provider", "fail", f"{provider} is configured but no API_KEY is set")
|
||||
endpoint = _endpoint(env["OPENAI_BASE_URL"]) if env.get("OPENAI_BASE_URL") else ""
|
||||
return Check("provider", "ok", f"{provider}{' at ' + endpoint if endpoint else ''}")
|
||||
|
||||
|
||||
def _redis_to_check(args, env: Mapping[str, str]) -> dict:
|
||||
"""The Redis endpoints to ping: all three from --redis-url when given, else what .env holds.
|
||||
|
||||
Overriding only the broker would still ping a stale result backend or cache, and the check
|
||||
fails on the first endpoint that does not answer -- so --redis-url would appear not to work.
|
||||
"""
|
||||
if args.redis_url:
|
||||
names = {"CELERY_BROKER_URL": "broker", "CELERY_RESULT_BACKEND": "results", "CACHE_REDIS_URL": "cache"}
|
||||
return {names[key]: value for key, value in _redis_urls(args.redis_url).items()}
|
||||
pairs = (
|
||||
("broker", env.get("CELERY_BROKER_URL")),
|
||||
("results", env.get("CELERY_RESULT_BACKEND")),
|
||||
("cache", env.get("CACHE_REDIS_URL")),
|
||||
)
|
||||
return {key: value for key, value in pairs if value}
|
||||
|
||||
|
||||
def doctor(args, context: Optional[Context] = None) -> int:
|
||||
"""Check what DocsGPT needs on this machine, and say what is missing."""
|
||||
from docsgpt.core import paths
|
||||
|
||||
if args.dir:
|
||||
env_path = stack.stack_dir(args.dir) / ".env"
|
||||
else:
|
||||
try:
|
||||
env_path = paths.env_file()
|
||||
except FileNotFoundError as exc:
|
||||
raise DeployError(str(exc)) from exc
|
||||
env = envfile.read(env_path)
|
||||
checks = [
|
||||
Check("settings", "ok" if env_path.is_file() else "warn",
|
||||
f"{env_path}" if env_path.is_file() else f"{env_path} does not exist yet; defaults are in use"),
|
||||
_check_postgres(args.postgres_uri or env.get("POSTGRES_URI")),
|
||||
_check_redis(_redis_to_check(args, env)),
|
||||
_check_provider(env),
|
||||
]
|
||||
|
||||
# The one command that exists to explain a broken setup must not fall over on one.
|
||||
port = _port_number(env["DOCSGPT_PORT"], f"DOCSGPT_PORT in {env_path}") if env.get("DOCSGPT_PORT") \
|
||||
else stack.DEFAULT_PORT
|
||||
if _port_is_free(port):
|
||||
checks.append(Check("port", "ok", f"{port} is free"))
|
||||
else:
|
||||
checks.append(Check("port", "warn", f"{port} is in use, which is expected if DocsGPT is running"))
|
||||
|
||||
directory = stack.stack_dir(args.dir)
|
||||
if _mode(directory) == "native":
|
||||
services = context.service_manager() if context else native.services_for_platform()
|
||||
names = _service_names(directory)
|
||||
running = [name for name in names if services.is_running(name)]
|
||||
level = "ok" if len(running) == len(names) else "warn"
|
||||
checks.append(Check("services", level, f"{len(running)} of {len(names)} running ({', '.join(names)})"))
|
||||
|
||||
for check in checks:
|
||||
print(f"[{MARKS[check.level]}] {check.name:<9} {check.detail}")
|
||||
failed = [check for check in checks if check.level == "fail"]
|
||||
if failed:
|
||||
print(f"\n{len(failed)} problem(s) to fix before DocsGPT will work.", file=sys.stderr)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def _chosen_services(names: tuple[str, str], wanted: list) -> list:
|
||||
"""The services the user asked for, given either short names (api) or full ones."""
|
||||
if not wanted:
|
||||
return list(names)
|
||||
chosen = []
|
||||
for ask in wanted:
|
||||
match = [name for name in names if name == ask or name.removeprefix("docsgpt-").startswith(ask)]
|
||||
if not match:
|
||||
raise DeployError(f"{ask!r} is not a service of this install; it has {', '.join(names)}.")
|
||||
chosen.extend(match)
|
||||
return chosen
|
||||
|
||||
|
||||
def restart(args, context: Optional[Context] = None) -> int:
|
||||
"""Restart the services, changing nothing else."""
|
||||
context = context or Context.default(args)
|
||||
directory = stack.stack_dir(args.dir)
|
||||
if _installed(directory) is None:
|
||||
return 1
|
||||
if _mode(directory) == "native":
|
||||
services = context.service_manager()
|
||||
chosen = _chosen_services(_service_names(directory), list(args.services))
|
||||
for name in reversed(chosen):
|
||||
services.stop(name)
|
||||
for name in chosen:
|
||||
services.start(name)
|
||||
print(f"Restarted {', '.join(chosen)}.")
|
||||
return 0
|
||||
return context.docker.compose(directory, "restart", *args.services, check=False).returncode
|
||||
|
||||
|
||||
def _stack_image(env: Mapping[str, str]) -> str:
|
||||
"""The image this install runs; the volume tars go through it, so nothing extra is pulled."""
|
||||
tag = env.get("DOCSGPT_IMAGE_TAG") or "latest"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""``docsgpt dev``: this checkout's API, worker and UI as children of one terminal.
|
||||
|
||||
``docsgpt up --native`` installs services meant to outlive the shell. Development wants the
|
||||
opposite: processes rooted in the checkout, restarting when a file is saved, logging into one
|
||||
terminal, and gone when Ctrl-C lands. This module decides which processes to run and supervises
|
||||
them; nothing here imports the app itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, TextIO
|
||||
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
MOCK_LLM_PORT = 8090
|
||||
UI_PORT = 5173
|
||||
STOP_GRACE = 10.0
|
||||
|
||||
# One colour per child so a glance at the terminal says who is talking.
|
||||
COLOURS = {"api": "\033[36m", "worker": "\033[35m", "ui": "\033[32m", "llm": "\033[33m"}
|
||||
RESET = "\033[0m"
|
||||
WIDTH = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class Child:
|
||||
"""One process ``docsgpt dev`` runs."""
|
||||
|
||||
name: str
|
||||
command: list[str]
|
||||
cwd: Path
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def watchfiles_available() -> bool:
|
||||
"""Whether the worker can be restarted on save; it arrives with uvicorn's standard extras."""
|
||||
try:
|
||||
import watchfiles # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _reloading_command(command: list[str], watched: Path) -> list[str]:
|
||||
"""``command`` under watchfiles, restarted when a Python file under ``watched`` changes."""
|
||||
return [sys.executable, "-m", "watchfiles", "--filter", "python", shlex.join(command), str(watched)]
|
||||
|
||||
|
||||
def plan(
|
||||
args,
|
||||
checkout: Path,
|
||||
*,
|
||||
watching: Optional[bool] = None,
|
||||
launcher: Optional[list[str]] = None,
|
||||
) -> list[Child]:
|
||||
"""The children to run, in the order they should start."""
|
||||
from docsgpt.deploy import stack
|
||||
|
||||
launcher = launcher or [sys.executable, "-m", "docsgpt"]
|
||||
watching = watchfiles_available() if watching is None else watching
|
||||
package = checkout / "docsgpt"
|
||||
environment = {"DOCSGPT_HOME": str(checkout)}
|
||||
children: list[Child] = []
|
||||
|
||||
if getattr(args, "mock_llm", False):
|
||||
script = checkout / "scripts" / "mock_llm.py"
|
||||
if not script.is_file():
|
||||
raise DeployError(f"{script} is missing, so there is no mock LLM to run.")
|
||||
children.append(
|
||||
Child(
|
||||
name="llm",
|
||||
command=[sys.executable, str(script), "--port", str(MOCK_LLM_PORT)],
|
||||
cwd=checkout,
|
||||
env=dict(environment),
|
||||
)
|
||||
)
|
||||
# The children read these from the environment, so the checkout's .env is left alone.
|
||||
chosen = stack.provider_settings(
|
||||
"openai-compatible", model="mock", base_url=f"http://127.0.0.1:{MOCK_LLM_PORT}/v1"
|
||||
)
|
||||
environment.update({key: value for key, value in chosen.items() if value is not None})
|
||||
|
||||
api = [*launcher, "api", "--host", args.host, "--port", str(args.port)]
|
||||
if getattr(args, "reload", True):
|
||||
api.append("--reload")
|
||||
children.append(Child(name="api", command=api, cwd=checkout, env=dict(environment)))
|
||||
|
||||
if getattr(args, "worker", True):
|
||||
worker = [*launcher, "worker", "-l", getattr(args, "loglevel", "INFO")]
|
||||
if getattr(args, "reload", True) and watching:
|
||||
worker = _reloading_command(worker, package)
|
||||
children.append(Child(name="worker", command=worker, cwd=checkout, env=dict(environment)))
|
||||
|
||||
if getattr(args, "ui", False):
|
||||
frontend = checkout / "frontend"
|
||||
if not (frontend / "node_modules").is_dir():
|
||||
raise DeployError(
|
||||
f"the frontend has no node_modules yet. Run `npm install --include=dev` in {frontend} "
|
||||
"and try again, or leave --ui off."
|
||||
)
|
||||
if not shutil.which("npm"):
|
||||
raise DeployError("npm is not on PATH, so the frontend dev server cannot start.")
|
||||
children.append(Child(name="ui", command=["npm", "run", "dev"], cwd=frontend, env=dict(environment)))
|
||||
|
||||
return children
|
||||
|
||||
|
||||
def _line(name: str, text: str, colour: bool) -> str:
|
||||
"""One output line, prefixed with the child that wrote it."""
|
||||
label = name.ljust(WIDTH)
|
||||
if colour:
|
||||
return f"{COLOURS.get(name, '')}{label}{RESET} | {text}"
|
||||
return f"{label} | {text}"
|
||||
|
||||
|
||||
def _pump(child: Child, process, out: TextIO, lock: threading.Lock, colour: bool) -> None:
|
||||
"""Copy one child's output to ``out``, a line at a time, prefixed."""
|
||||
stream = process.stdout
|
||||
if stream is None:
|
||||
return
|
||||
for text in stream:
|
||||
with lock:
|
||||
out.write(_line(child.name, text.rstrip("\n"), colour))
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
|
||||
|
||||
def _signal(process, number: int) -> None:
|
||||
"""Signal a child and, on POSIX, everything it started."""
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.terminate()
|
||||
return
|
||||
os.killpg(os.getpgid(process.pid), number)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
# The child has already gone, or its group is no longer ours to signal. Either way there is
|
||||
# nothing left to stop, and shutdown must not fail on the thing it is trying to clean up.
|
||||
pass
|
||||
|
||||
|
||||
def _stop(running: list[tuple[Child, object]], grace: float, sleep: Callable[[float], None]) -> None:
|
||||
"""Interrupt the children, then insist if they are still there.
|
||||
|
||||
A second Ctrl-C lands while this is waiting. It means "stop waiting", not "give up": the wait
|
||||
ends and the children are killed, rather than the interrupt escaping and leaving them running.
|
||||
"""
|
||||
for _, process in running:
|
||||
if process.poll() is None:
|
||||
_signal(process, signal.SIGINT)
|
||||
deadline = time.monotonic() + grace
|
||||
while time.monotonic() < deadline and any(process.poll() is None for _, process in running):
|
||||
try:
|
||||
sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
for _, process in running:
|
||||
if process.poll() is None:
|
||||
_signal(process, signal.SIGKILL)
|
||||
|
||||
|
||||
def run(
|
||||
children: list[Child],
|
||||
*,
|
||||
out: TextIO = sys.stdout,
|
||||
spawn: Callable[..., object] = subprocess.Popen,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
grace: float = STOP_GRACE,
|
||||
colour: Optional[bool] = None,
|
||||
) -> int:
|
||||
"""Start the children and keep them running until one exits or the terminal interrupts."""
|
||||
colour = out.isatty() if colour is None else colour
|
||||
lock = threading.Lock()
|
||||
running: list[tuple[Child, object]] = []
|
||||
pumps: list[threading.Thread] = []
|
||||
try:
|
||||
for child in children:
|
||||
process = spawn(
|
||||
child.command,
|
||||
cwd=str(child.cwd),
|
||||
env={**os.environ, **child.env},
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
# Its own session, so Ctrl-C reaches this process and the children are stopped in order.
|
||||
start_new_session=os.name != "nt",
|
||||
)
|
||||
running.append((child, process))
|
||||
pump = threading.Thread(target=_pump, args=(child, process, out, lock, colour), daemon=True)
|
||||
pump.start()
|
||||
pumps.append(pump)
|
||||
|
||||
while True:
|
||||
for child, process in running:
|
||||
code = process.poll()
|
||||
if code is not None:
|
||||
with lock:
|
||||
out.write(_line(child.name, f"exited with {code}", colour))
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
return code or 1
|
||||
sleep(0.2)
|
||||
except KeyboardInterrupt:
|
||||
with lock:
|
||||
out.write("\nStopping ...\n")
|
||||
out.flush()
|
||||
return 0
|
||||
finally:
|
||||
_stop(running, grace, sleep)
|
||||
# Join the readers: a child's last lines are still in flight when it exits, and dropping
|
||||
# them loses exactly the output that says why it stopped.
|
||||
for pump in pumps:
|
||||
pump.join(timeout=grace)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""`docsgpt dev`: the checkout's processes as children of one terminal."""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.deploy import dev
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
|
||||
def _args(**overrides):
|
||||
values = {"host": "127.0.0.1", "port": 7091, "ui": False, "mock_llm": False,
|
||||
"worker": True, "reload": True, "loglevel": "INFO"}
|
||||
values.update(overrides)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
def _named(children):
|
||||
return [child.name for child in children]
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
"""A child that produces the given lines and is done."""
|
||||
|
||||
def __init__(self, lines=(), code=None):
|
||||
self.stdout = iter(list(lines))
|
||||
self.code = code
|
||||
self.pid = -1
|
||||
|
||||
def poll(self):
|
||||
return self.code
|
||||
|
||||
def terminate(self):
|
||||
self.code = self.code if self.code is not None else -15
|
||||
|
||||
|
||||
class TestPlan:
|
||||
def test_the_api_and_worker_run_from_the_checkout(self, tmp_path):
|
||||
children = dev.plan(_args(), tmp_path, watching=False)
|
||||
assert _named(children) == ["api", "worker"]
|
||||
api, worker = children
|
||||
assert api.command[-6:-1] == ["api", "--host", "127.0.0.1", "--port", "7091"]
|
||||
assert api.command[-1] == "--reload"
|
||||
assert worker.command[-2:] == ["-l", "INFO"]
|
||||
for child in children:
|
||||
assert child.cwd == tmp_path
|
||||
assert child.env["DOCSGPT_HOME"] == str(tmp_path), "the checkout is the data home, not ~/.docsgpt"
|
||||
|
||||
def test_the_worker_restarts_on_save_when_watchfiles_is_there(self, tmp_path):
|
||||
"""Celery has no reloader of its own, so it is wrapped in one."""
|
||||
worker = dev.plan(_args(), tmp_path, watching=True)[1]
|
||||
assert "watchfiles" in worker.command
|
||||
assert str(tmp_path / "docsgpt") in worker.command, "it watches the package, not the whole checkout"
|
||||
assert "docsgpt worker" in " ".join(worker.command)
|
||||
|
||||
def test_without_watchfiles_the_worker_still_runs(self, tmp_path):
|
||||
worker = dev.plan(_args(), tmp_path, watching=False)[1]
|
||||
assert "watchfiles" not in " ".join(worker.command)
|
||||
|
||||
def test_no_reload_leaves_both_alone(self, tmp_path):
|
||||
children = dev.plan(_args(reload=False), tmp_path, watching=True)
|
||||
assert "--reload" not in children[0].command
|
||||
assert "watchfiles" not in " ".join(children[1].command)
|
||||
|
||||
def test_no_worker(self, tmp_path):
|
||||
assert _named(dev.plan(_args(worker=False), tmp_path, watching=False)) == ["api"]
|
||||
|
||||
def test_the_mock_llm_starts_first_and_the_others_are_pointed_at_it(self, tmp_path):
|
||||
"""A dev loop that needs no API key: the mock has to be up before the API asks it anything."""
|
||||
(tmp_path / "scripts").mkdir()
|
||||
(tmp_path / "scripts" / "mock_llm.py").write_text("", encoding="utf-8")
|
||||
children = dev.plan(_args(mock_llm=True), tmp_path, watching=False)
|
||||
assert _named(children) == ["llm", "api", "worker"]
|
||||
api = children[1]
|
||||
assert api.env["LLM_PROVIDER"] == "openai"
|
||||
assert api.env["OPENAI_BASE_URL"] == f"http://127.0.0.1:{dev.MOCK_LLM_PORT}/v1"
|
||||
assert api.env["API_KEY"], "the client wants some key, even a placeholder"
|
||||
assert "OPENAI_BASE_URL" not in children[0].env, "the mock itself does not need pointing at itself"
|
||||
|
||||
def test_a_missing_mock_llm_script_is_reported(self, tmp_path):
|
||||
with pytest.raises(DeployError, match="mock LLM"):
|
||||
dev.plan(_args(mock_llm=True), tmp_path, watching=False)
|
||||
|
||||
def test_the_ui_needs_its_dependencies(self, tmp_path):
|
||||
(tmp_path / "frontend").mkdir()
|
||||
with pytest.raises(DeployError, match="node_modules"):
|
||||
dev.plan(_args(ui=True), tmp_path, watching=False)
|
||||
|
||||
def test_the_ui_needs_npm_on_path(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "frontend" / "node_modules").mkdir(parents=True)
|
||||
monkeypatch.setattr(dev.shutil, "which", lambda name: None)
|
||||
with pytest.raises(DeployError, match="npm"):
|
||||
dev.plan(_args(ui=True), tmp_path, watching=False)
|
||||
|
||||
def test_the_ui_runs_in_the_frontend_directory(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "frontend" / "node_modules").mkdir(parents=True)
|
||||
monkeypatch.setattr(dev.shutil, "which", lambda name: "/usr/local/bin/npm")
|
||||
children = dev.plan(_args(ui=True), tmp_path, watching=False)
|
||||
ui = children[-1]
|
||||
assert ui.name == "ui"
|
||||
assert ui.command == ["npm", "run", "dev"]
|
||||
assert ui.cwd == tmp_path / "frontend"
|
||||
|
||||
|
||||
class TestRun:
|
||||
def _spawn(self, processes):
|
||||
made = iter(processes)
|
||||
|
||||
def spawn(command, **kwargs):
|
||||
return next(made)
|
||||
|
||||
return spawn
|
||||
|
||||
def test_output_is_prefixed_with_the_child_that_wrote_it(self, tmp_path):
|
||||
out = io.StringIO()
|
||||
children = [dev.Child(name="api", command=["true"], cwd=tmp_path)]
|
||||
code = dev.run(children, out=out, spawn=self._spawn([FakeProcess(["hello\n"], code=0)]),
|
||||
sleep=lambda _: None, colour=False)
|
||||
assert "api | hello" in out.getvalue()
|
||||
assert code == 1, "a child that ends by itself ends the session, however it exited"
|
||||
|
||||
def test_output_in_flight_is_not_lost_when_run_returns(self, tmp_path):
|
||||
"""The reader is a thread: without joining it, a child's last lines can never be printed."""
|
||||
out = io.StringIO()
|
||||
lines = [f"line {number}\n" for number in range(200)]
|
||||
children = [dev.Child(name="api", command=["x"], cwd=tmp_path)]
|
||||
code = dev.run(children, out=out, spawn=self._spawn([FakeProcess(lines, code=0)]),
|
||||
sleep=lambda _: None, colour=False)
|
||||
assert code == 1
|
||||
printed = out.getvalue()
|
||||
assert "line 0" in printed and "line 199" in printed, "every line the child wrote is printed"
|
||||
|
||||
def test_a_failing_child_returns_its_code(self, tmp_path):
|
||||
out = io.StringIO()
|
||||
children = [dev.Child(name="api", command=["false"], cwd=tmp_path)]
|
||||
code = dev.run(children, out=out, spawn=self._spawn([FakeProcess(code=2)]),
|
||||
sleep=lambda _: None, colour=False)
|
||||
assert code == 2
|
||||
assert "exited with 2" in out.getvalue()
|
||||
|
||||
def test_an_interrupt_stops_quietly(self, tmp_path):
|
||||
out = io.StringIO()
|
||||
|
||||
def sleep(_):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
children = [dev.Child(name="api", command=["sleep"], cwd=tmp_path)]
|
||||
code = dev.run(children, out=out, spawn=self._spawn([FakeProcess()]), sleep=sleep, colour=False)
|
||||
assert code == 0
|
||||
assert "Stopping" in out.getvalue()
|
||||
|
||||
def test_a_second_interrupt_during_shutdown_still_kills(self, tmp_path, monkeypatch):
|
||||
"""Ctrl-C twice is what you press when it did not die; it must not leave children behind."""
|
||||
sent = []
|
||||
monkeypatch.setattr(dev, "_signal", lambda process, number: sent.append(number))
|
||||
|
||||
def sleep(_):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
children = [dev.Child(name="api", command=["sleep"], cwd=tmp_path)]
|
||||
code = dev.run(children, out=io.StringIO(), spawn=self._spawn([FakeProcess()]),
|
||||
sleep=sleep, colour=False, grace=5)
|
||||
assert code == 0
|
||||
assert signal.SIGINT in sent
|
||||
assert signal.SIGKILL in sent, "the second interrupt escalates instead of escaping"
|
||||
|
||||
def test_children_get_the_checkout_environment(self, tmp_path, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def spawn(command, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return FakeProcess(code=0)
|
||||
|
||||
monkeypatch.setenv("SOMETHING_ELSE", "kept")
|
||||
children = [dev.Child(name="api", command=["x"], cwd=tmp_path, env={"DOCSGPT_HOME": str(tmp_path)})]
|
||||
dev.run(children, out=io.StringIO(), spawn=spawn, sleep=lambda _: None, colour=False)
|
||||
assert seen["env"]["DOCSGPT_HOME"] == str(tmp_path)
|
||||
assert seen["env"]["SOMETHING_ELSE"] == "kept", "the shell's environment is kept, not replaced"
|
||||
assert seen["cwd"] == str(tmp_path)
|
||||
assert seen["start_new_session"] is (sys.platform != "win32")
|
||||
|
||||
|
||||
class TestReloadingCommand:
|
||||
def test_an_interpreter_path_with_spaces_survives(self):
|
||||
command = dev._reloading_command(["/opt/my venv/bin/python", "-m", "docsgpt", "worker"], Path("/srv/pkg"))
|
||||
assert "'/opt/my venv/bin/python' -m docsgpt worker" in command
|
||||
assert command[-1] == "/srv/pkg"
|
||||
@@ -0,0 +1,544 @@
|
||||
"""`docsgpt doctor`, `restart`, following native logs, and settings that apply themselves."""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.deploy import commands, envfile
|
||||
from docsgpt.deploy.docker import DeployError
|
||||
|
||||
from .test_commands import FakeDocker, _context, _run
|
||||
from .test_native import FakeServices, _names, _native_context
|
||||
|
||||
|
||||
def _installed_native(tmp_path, services):
|
||||
argv = ["up", "--native", "--dir", str(tmp_path), "--yes", "--postgres-uri", "postgresql://localhost/d"]
|
||||
assert _run(argv, _native_context(services)) == 0
|
||||
|
||||
|
||||
class TestDevCommand:
|
||||
def _checkout(self, monkeypatch, tmp_path):
|
||||
from docsgpt.core import paths
|
||||
|
||||
monkeypatch.setattr(paths, "checkout_root", lambda: tmp_path)
|
||||
|
||||
def _free_port(self):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
def test_an_installed_package_is_pointed_at_native_mode(self, monkeypatch):
|
||||
"""`dev` runs the code you are editing; there is none to edit outside a checkout."""
|
||||
from docsgpt.core import paths
|
||||
|
||||
monkeypatch.setattr(paths, "checkout_root", lambda: None)
|
||||
with pytest.raises(DeployError, match="source checkout"):
|
||||
_run(["dev"], _context())
|
||||
|
||||
def test_a_busy_port_is_refused_before_anything_starts(self, monkeypatch, tmp_path):
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
self._checkout(monkeypatch, tmp_path)
|
||||
started = []
|
||||
monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held:
|
||||
held.bind(("127.0.0.1", 0))
|
||||
held.listen(1)
|
||||
port = held.getsockname()[1]
|
||||
with pytest.raises(DeployError, match="already in use"):
|
||||
_run(["dev", "--port", str(port)], _context())
|
||||
assert started == [], "nothing is spawned when the port is taken"
|
||||
|
||||
@pytest.mark.parametrize("port", ["0", "-1", "70000"])
|
||||
def test_a_port_argparse_accepts_but_a_socket_cannot_use(self, monkeypatch, tmp_path, port):
|
||||
"""argparse takes any integer: 0 would serve on an ephemeral port while printing 0."""
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
self._checkout(monkeypatch, tmp_path)
|
||||
started = []
|
||||
monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0)
|
||||
with pytest.raises(DeployError, match="not a port number"):
|
||||
_run(["dev", "--port", port], _context())
|
||||
assert started == []
|
||||
|
||||
def test_a_busy_mock_llm_port_is_refused_before_anything_starts(self, monkeypatch, tmp_path):
|
||||
"""It starts first and the rest are pointed at it, so a clash cannot wait until spawn time."""
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
self._checkout(monkeypatch, tmp_path)
|
||||
(tmp_path / "scripts").mkdir()
|
||||
(tmp_path / "scripts" / "mock_llm.py").write_text("", encoding="utf-8")
|
||||
started = []
|
||||
monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held:
|
||||
held.bind(("127.0.0.1", 0))
|
||||
held.listen(1)
|
||||
monkeypatch.setattr(dev_module, "MOCK_LLM_PORT", held.getsockname()[1])
|
||||
with pytest.raises(DeployError, match="mock LLM cannot bind"):
|
||||
_run(["dev", "--mock-llm", "--port", str(self._free_port())], _context())
|
||||
assert started == [], "nothing is spawned when the mock LLM has nowhere to listen"
|
||||
|
||||
def test_the_api_cannot_be_given_the_mock_llm_port(self, monkeypatch, tmp_path):
|
||||
"""Both free-port checks pass here: the port is free, and then two children want it."""
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
self._checkout(monkeypatch, tmp_path)
|
||||
(tmp_path / "scripts").mkdir()
|
||||
(tmp_path / "scripts" / "mock_llm.py").write_text("", encoding="utf-8")
|
||||
started = []
|
||||
monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0)
|
||||
monkeypatch.setattr(dev_module, "MOCK_LLM_PORT", self._free_port())
|
||||
with pytest.raises(DeployError, match="mock LLM listens"):
|
||||
_run(["dev", "--mock-llm", "--port", str(dev_module.MOCK_LLM_PORT)], _context())
|
||||
assert started == [], "nothing is spawned when the two would collide"
|
||||
|
||||
def test_it_runs_the_children_it_planned_and_says_where(self, monkeypatch, tmp_path, capsys):
|
||||
from docsgpt.deploy import dev as dev_module
|
||||
|
||||
self._checkout(monkeypatch, tmp_path)
|
||||
started = []
|
||||
monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0)
|
||||
port = self._free_port()
|
||||
assert _run(["dev", "--port", str(port)], _context()) == 0
|
||||
assert [child.name for child in started[0]] == ["api", "worker"]
|
||||
printed = capsys.readouterr().out
|
||||
assert f"http://127.0.0.1:{port}" in printed
|
||||
assert "Ctrl-C" in printed
|
||||
|
||||
|
||||
class TestRestart:
|
||||
def test_it_stops_and_starts_both_without_touching_settings(self, tmp_path):
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
before = (tmp_path / ".env").read_text(encoding="utf-8")
|
||||
services.started.clear()
|
||||
services.stopped.clear()
|
||||
|
||||
assert _run(["restart", "--dir", str(tmp_path)], _native_context(services)) == 0
|
||||
assert services.stopped == list(reversed(_names(tmp_path))), "the worker goes down first"
|
||||
assert services.started == list(_names(tmp_path))
|
||||
assert (tmp_path / ".env").read_text(encoding="utf-8") == before
|
||||
|
||||
def test_one_service_by_its_short_name(self, tmp_path):
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
services.started.clear()
|
||||
assert _run(["restart", "api", "--dir", str(tmp_path)], _native_context(services)) == 0
|
||||
assert services.started == [_names(tmp_path)[0]]
|
||||
|
||||
def test_a_name_this_install_does_not_have(self, tmp_path):
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
with pytest.raises(DeployError, match="not a service"):
|
||||
_run(["restart", "frontend", "--dir", str(tmp_path)], _native_context(services))
|
||||
|
||||
def test_a_docker_install_restarts_its_containers(self, tmp_path):
|
||||
assert _run(["up", "--yes", "--dir", str(tmp_path)], _context()) == 0
|
||||
docker = FakeDocker()
|
||||
assert _run(["restart", "--dir", str(tmp_path)], _context(docker)) == 0
|
||||
assert ["restart"] in [args for _, args in docker.calls]
|
||||
|
||||
|
||||
class TestEnvApplies:
|
||||
def test_a_running_native_install_restarts_itself(self, tmp_path, capsys):
|
||||
"""The old advice was to run `docsgpt up` again, which reruns migrations to change one value."""
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
services.started.clear()
|
||||
|
||||
argv = ["env", "--dir", str(tmp_path), "set", "LLM_NAME=gpt-4o"]
|
||||
assert _run(argv, _native_context(services)) == 0
|
||||
assert envfile.read(tmp_path / ".env")["LLM_NAME"] == "gpt-4o"
|
||||
assert services.started == list(_names(tmp_path))
|
||||
assert "Restarted" in capsys.readouterr().out
|
||||
|
||||
def test_no_restart_leaves_the_services_alone(self, tmp_path, capsys):
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
services.started.clear()
|
||||
|
||||
argv = ["env", "--dir", str(tmp_path), "set", "LLM_NAME=gpt-4o", "--no-restart"]
|
||||
assert _run(argv, _native_context(services)) == 0
|
||||
assert services.started == []
|
||||
assert "docsgpt up" in capsys.readouterr().out
|
||||
|
||||
def test_a_stopped_install_is_not_started_by_a_settings_change(self, tmp_path):
|
||||
services = FakeServices()
|
||||
_installed_native(tmp_path, services)
|
||||
for name in _names(tmp_path):
|
||||
services.stop(name)
|
||||
services.started.clear()
|
||||
|
||||
argv = ["env", "--dir", str(tmp_path), "set", "LLM_NAME=gpt-4o"]
|
||||
assert _run(argv, _native_context(services)) == 0
|
||||
assert services.started == [], "changing a setting does not start a stopped install"
|
||||
|
||||
|
||||
class TestFollowLogs:
|
||||
def test_it_prints_lines_written_after_it_started(self, tmp_path, capsys, monkeypatch):
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir()
|
||||
(logs / "api.log").write_text("old line\n", encoding="utf-8")
|
||||
|
||||
rounds = {"n": 0}
|
||||
|
||||
def sleep(_):
|
||||
rounds["n"] += 1
|
||||
if rounds["n"] == 1:
|
||||
(logs / "api.log").open("a", encoding="utf-8").write("new line\n")
|
||||
return
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(commands.time, "sleep", sleep)
|
||||
assert commands._follow(logs, ["api"]) == 0
|
||||
printed = capsys.readouterr().out
|
||||
assert "api | new line" in printed
|
||||
assert "old line" not in printed, "it starts at the end, like tail -f"
|
||||
|
||||
|
||||
class TestLogsTransition:
|
||||
def test_a_line_written_between_the_read_and_the_follow_is_not_lost(self, tmp_path, capsys, monkeypatch):
|
||||
"""The read and the follow used to open the file twice, and whatever landed between was gone."""
|
||||
services = FakeServices()
|
||||
argv = ["up", "--native", "--dir", str(tmp_path), "--yes", "--postgres-uri", "postgresql://localhost/d"]
|
||||
assert _run(argv, _native_context(services)) == 0
|
||||
logs = tmp_path / "logs"
|
||||
(logs / "api.log").write_text("first\n", encoding="utf-8")
|
||||
|
||||
def sleep(_):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
original = commands._follow
|
||||
|
||||
def follow(logs_dir, wanted, handles=None):
|
||||
# Written after the read, before following starts.
|
||||
(logs / "api.log").open("a", encoding="utf-8").write("during\n")
|
||||
monkeypatch.setattr(commands.time, "sleep", sleep)
|
||||
return original(logs_dir, wanted, handles)
|
||||
|
||||
monkeypatch.setattr(commands, "_follow", follow)
|
||||
assert _run(["logs", "-f", "api", "--dir", str(tmp_path)], _native_context(services)) == 0
|
||||
printed = capsys.readouterr().out
|
||||
assert "first" in printed
|
||||
assert "during" in printed, "the line written during the handover has to appear"
|
||||
|
||||
|
||||
class TestChecks:
|
||||
def test_the_public_api_needs_no_key(self):
|
||||
check = commands._check_provider({"LLM_PROVIDER": "docsgpt"})
|
||||
assert check.level == "ok"
|
||||
|
||||
def test_a_provider_without_a_key_is_a_problem(self):
|
||||
check = commands._check_provider({"LLM_PROVIDER": "openai"})
|
||||
assert check.level == "fail"
|
||||
assert "API_KEY" in check.detail
|
||||
|
||||
def test_a_provider_with_a_key_and_a_base_url(self):
|
||||
check = commands._check_provider(
|
||||
{"LLM_PROVIDER": "openai", "API_KEY": "x", "OPENAI_BASE_URL": "http://localhost:8090/v1"}
|
||||
)
|
||||
assert check.level == "ok"
|
||||
assert "8090" in check.detail
|
||||
|
||||
def test_a_provider_base_url_is_printed_without_its_credentials(self):
|
||||
"""An OpenAI-compatible endpoint can carry userinfo, and doctor prints its detail."""
|
||||
url = "https://someone:sEcReTtOkEn@models.example.com/v1"
|
||||
check = commands._check_provider({"LLM_PROVIDER": "openai", "API_KEY": "x", "OPENAI_BASE_URL": url})
|
||||
assert check.level == "ok"
|
||||
assert "sEcReTtOkEn" not in check.detail
|
||||
assert "someone" not in check.detail
|
||||
# Compared with what the sanitiser produced rather than a host substring, which is both the
|
||||
# real contract and the pattern CodeQL warns about.
|
||||
assert check.detail == f"openai at {commands._endpoint(url)}"
|
||||
|
||||
def test_services_are_named_for_the_install(self, tmp_path):
|
||||
names = _names(tmp_path)
|
||||
assert commands._chosen_services(names, []) == list(names)
|
||||
assert commands._chosen_services(names, ["worker"]) == [names[1]]
|
||||
assert commands._chosen_services(names, [names[0]]) == [names[0]]
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""Answers doctor's three queries in order: server version, whether the table is there, the revision."""
|
||||
|
||||
def __init__(self, version, table, revision):
|
||||
self.answers = [(version,), (table,), (revision,) if revision else None]
|
||||
self.given = 0
|
||||
self.statements = []
|
||||
|
||||
def execute(self, statement):
|
||||
self.statements.append(statement)
|
||||
|
||||
def fetchone(self):
|
||||
answer = self.answers[self.given]
|
||||
self.given += 1
|
||||
return answer
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exception):
|
||||
return False
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exception):
|
||||
return False
|
||||
|
||||
|
||||
def _postgres_answering(monkeypatch, version="16.2", table="alembic_version", revision="0031_x", head="0031_x"):
|
||||
import psycopg
|
||||
|
||||
cursor = FakeCursor(version, table, revision)
|
||||
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: FakeConnection(cursor))
|
||||
monkeypatch.setattr(commands, "_migration_head", lambda: head)
|
||||
return cursor
|
||||
|
||||
|
||||
class TestPostgresCheck:
|
||||
def test_at_head(self, monkeypatch):
|
||||
_postgres_answering(monkeypatch)
|
||||
check = commands._check_postgres("postgresql://localhost/d")
|
||||
assert check.level == "ok"
|
||||
assert "16.2" in check.detail and "0031_x" in check.detail
|
||||
|
||||
def test_both_queries_resolve_the_same_table(self, monkeypatch):
|
||||
"""Alembic sets no version_table_schema, so the table follows search_path; asserting a
|
||||
schema in one query and not the other is how doctor called a migrated database empty."""
|
||||
cursor = _postgres_answering(monkeypatch)
|
||||
commands._check_postgres("postgresql://localhost/d")
|
||||
looked_up = [statement for statement in cursor.statements if "to_regclass" in statement]
|
||||
read = [statement for statement in cursor.statements if "version_num" in statement]
|
||||
assert looked_up and read
|
||||
assert all("public." not in statement for statement in looked_up + read), (
|
||||
"neither query may pin a schema alembic never promised"
|
||||
)
|
||||
|
||||
def test_a_database_with_no_schema_yet(self, monkeypatch):
|
||||
"""The commonest first-run state: the database exists, nothing has been migrated into it."""
|
||||
_postgres_answering(monkeypatch, table=None, revision=None)
|
||||
check = commands._check_postgres("postgresql://localhost/d")
|
||||
assert check.level == "fail"
|
||||
assert "docsgpt migrate" in check.detail
|
||||
|
||||
def test_a_schema_behind_this_version(self, monkeypatch):
|
||||
_postgres_answering(monkeypatch, revision="0029_old", head="0031_x")
|
||||
check = commands._check_postgres("postgresql://localhost/d")
|
||||
assert check.level == "fail"
|
||||
assert "0029_old" in check.detail and "0031_x" in check.detail
|
||||
assert "docsgpt migrate" in check.detail
|
||||
|
||||
def test_a_database_that_does_not_answer(self, monkeypatch):
|
||||
import psycopg
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise psycopg.OperationalError("connection refused")
|
||||
|
||||
monkeypatch.setattr(psycopg, "connect", refuse)
|
||||
check = commands._check_postgres("postgresql://localhost/d")
|
||||
assert check.level == "fail"
|
||||
assert "connection refused" in check.detail
|
||||
|
||||
def test_a_database_password_is_never_printed(self, monkeypatch):
|
||||
"""psycopg quotes the connection string it was given, which carries the password."""
|
||||
import psycopg
|
||||
|
||||
uri = "postgresql://docsgpt:hunter2@db.example.com:5432/docsgpt"
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise psycopg.OperationalError(f'connection to "{uri}" failed: timeout expired')
|
||||
|
||||
monkeypatch.setattr(psycopg, "connect", refuse)
|
||||
check = commands._check_postgres(uri)
|
||||
assert check.level == "fail"
|
||||
assert "hunter2" not in check.detail
|
||||
# Compared against what the sanitiser produced, not a host substring: asking whether a URL
|
||||
# contains a host is the check CodeQL warns about, and it is not what this test means.
|
||||
assert check.detail.startswith(f"cannot connect to {commands._endpoint(uri)}")
|
||||
assert "timeout expired" in check.detail, "and the reason has to survive the scrubbing"
|
||||
|
||||
def test_without_a_uri_at_all(self):
|
||||
check = commands._check_postgres(None)
|
||||
assert check.level == "fail"
|
||||
assert "POSTGRES_URI" in check.detail
|
||||
|
||||
|
||||
class TestRedisCheck:
|
||||
def test_every_database_answering(self, monkeypatch):
|
||||
import redis
|
||||
|
||||
monkeypatch.setattr(redis.Redis, "from_url", classmethod(lambda cls, url, **k: type("R", (), {"ping": lambda self: True})()))
|
||||
check = commands._check_redis({"broker": "redis://localhost:6379/0", "cache": "redis://localhost:6379/2"})
|
||||
assert check.level == "ok"
|
||||
assert "2" in check.detail
|
||||
|
||||
def test_the_one_that_does_not_answer_is_named(self, monkeypatch):
|
||||
"""Three URLs usually differ only by database number, so the message has to say which."""
|
||||
import redis
|
||||
|
||||
def from_url(cls, url, **kwargs):
|
||||
class Client:
|
||||
def ping(self):
|
||||
raise ConnectionError(f"no route to {url}")
|
||||
|
||||
return Client()
|
||||
|
||||
monkeypatch.setattr(redis.Redis, "from_url", classmethod(from_url))
|
||||
url = "redis://localhost:6379/2"
|
||||
check = commands._check_redis({"cache": url})
|
||||
assert check.level == "fail"
|
||||
assert check.detail.startswith(f"cache ({commands._endpoint(url)}) does not answer")
|
||||
|
||||
def test_a_password_in_the_url_is_never_printed(self, monkeypatch):
|
||||
"""doctor output goes into terminals, CI logs and pasted issue reports."""
|
||||
import redis
|
||||
|
||||
def from_url(cls, url, **kwargs):
|
||||
class Client:
|
||||
def ping(self):
|
||||
# redis-py quotes the URL it was handed, which is how the credentials got out.
|
||||
raise ConnectionError(f"no route to {url}")
|
||||
|
||||
return Client()
|
||||
|
||||
monkeypatch.setattr(redis.Redis, "from_url", classmethod(from_url))
|
||||
url = "rediss://default:sUpErSeCrEt@redis.example.com:6380/0"
|
||||
check = commands._check_redis({"broker": url})
|
||||
assert check.level == "fail"
|
||||
assert "sUpErSeCrEt" not in check.detail
|
||||
assert "default" not in check.detail
|
||||
assert check.detail.startswith(f"broker ({commands._endpoint(url)}) does not answer")
|
||||
|
||||
@pytest.mark.parametrize("url", ["redis://[::1", "redis://localhost:not-a-port/0"])
|
||||
def test_a_malformed_url_is_not_echoed_either(self, url):
|
||||
"""urlsplit raises on the first; on the second it succeeds and .port raises on access."""
|
||||
assert commands._endpoint(url) == "the configured URL"
|
||||
|
||||
def test_a_failure_on_a_url_with_an_unparsable_port_is_still_a_check(self, monkeypatch):
|
||||
"""The check catches the client error and then formats it, which is where this raised."""
|
||||
import redis
|
||||
|
||||
def from_url(cls, url, **kwargs):
|
||||
class Client:
|
||||
def ping(self):
|
||||
raise ConnectionError(f"no route to {url}")
|
||||
|
||||
return Client()
|
||||
|
||||
monkeypatch.setattr(redis.Redis, "from_url", classmethod(from_url))
|
||||
check = commands._check_redis({"broker": "redis://localhost:not-a-port/0"})
|
||||
assert check.level == "fail", "a bad port is a finding, not a traceback"
|
||||
assert "the configured URL" in check.detail
|
||||
|
||||
def test_without_any_redis_configured(self):
|
||||
assert commands._check_redis({}).level == "fail"
|
||||
|
||||
|
||||
class TestMigrationHead:
|
||||
def test_it_finds_the_revision_this_package_ships(self):
|
||||
"""No database needed: this is the alembic.ini path resolution, which breaks silently."""
|
||||
head = commands._migration_head()
|
||||
assert head, "the packaged alembic.ini should resolve to a revision"
|
||||
assert head[0].isdigit(), head
|
||||
|
||||
|
||||
class TestRedisSelection:
|
||||
def test_redis_url_replaces_every_endpoint(self):
|
||||
"""Overriding only the broker would still ping a stale backend, and one failure fails all."""
|
||||
args = argparse.Namespace(redis_url="redis://given:6379/5")
|
||||
stale = {
|
||||
"CELERY_BROKER_URL": "redis://old:6379/0",
|
||||
"CELERY_RESULT_BACKEND": "redis://old:6379/1",
|
||||
"CACHE_REDIS_URL": "redis://old:6379/2",
|
||||
}
|
||||
chosen = commands._redis_to_check(args, stale)
|
||||
assert set(chosen) == {"broker", "results", "cache"}
|
||||
assert all("old" not in url for url in chosen.values()), chosen
|
||||
assert chosen["broker"].endswith("/5") and chosen["cache"].endswith("/7")
|
||||
|
||||
def test_without_the_flag_the_settings_are_used(self):
|
||||
args = argparse.Namespace(redis_url=None)
|
||||
chosen = commands._redis_to_check(args, {"CELERY_BROKER_URL": "redis://localhost:6379/0"})
|
||||
assert chosen == {"broker": "redis://localhost:6379/0"}
|
||||
|
||||
|
||||
class TestRedisUrlErrors:
|
||||
"""The validator raises before any check runs, and cli.main prints what it raises."""
|
||||
|
||||
SECRET = "sUpErSeCrEt"
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"redis://user:sUpErSeCrEt@host:not-a-port/0",
|
||||
"redis://user:sUpErSeCrEt@host:0/0",
|
||||
"redis://user:sUpErSeCrEt@host:6379/queue",
|
||||
"postgres://user:sUpErSeCrEt@host:6379/0",
|
||||
"redis://user:sUpErSeCrEt@host:6379/" + "1" * 5000,
|
||||
])
|
||||
def test_a_rejected_url_never_carries_its_password_into_the_error(self, tmp_path, url):
|
||||
(tmp_path / ".env").write_text("LLM_PROVIDER=docsgpt\n", encoding="utf-8")
|
||||
with pytest.raises(DeployError) as raised:
|
||||
_run(["doctor", "--dir", str(tmp_path), "--redis-url", url], _context())
|
||||
message = str(raised.value)
|
||||
assert self.SECRET not in message
|
||||
assert "user" not in message
|
||||
assert len(message) < 400, "an over-long database number must not come back in the message"
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
def _only(self, monkeypatch, postgres, redis):
|
||||
monkeypatch.setattr(commands, "_check_postgres", lambda uri: postgres)
|
||||
monkeypatch.setattr(commands, "_check_redis", lambda urls: redis)
|
||||
|
||||
def test_it_reports_every_check_and_succeeds_when_they_pass(self, tmp_path, capsys, monkeypatch):
|
||||
self._only(
|
||||
monkeypatch,
|
||||
commands.Check("postgres", "ok", "PostgreSQL 16.2, schema at 0031"),
|
||||
commands.Check("redis", "ok", "answering on 3 database(s)"),
|
||||
)
|
||||
(tmp_path / ".env").write_text("LLM_PROVIDER=docsgpt\n", encoding="utf-8")
|
||||
assert _run(["doctor", "--dir", str(tmp_path)], _context()) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "postgres" in out and "redis" in out and "provider" in out
|
||||
|
||||
def test_a_failing_check_makes_it_exit_one(self, tmp_path, capsys, monkeypatch):
|
||||
self._only(
|
||||
monkeypatch,
|
||||
commands.Check("postgres", "fail", "cannot connect: refused"),
|
||||
commands.Check("redis", "ok", "answering"),
|
||||
)
|
||||
(tmp_path / ".env").write_text("LLM_PROVIDER=docsgpt\n", encoding="utf-8")
|
||||
assert _run(["doctor", "--dir", str(tmp_path)], _context()) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "FAIL" in captured.out
|
||||
assert "1 problem" in captured.err
|
||||
|
||||
def test_a_hand_edited_port_is_reported_not_raised(self, tmp_path, monkeypatch):
|
||||
"""Doctor exists to explain a broken setup, so it must not traceback on one."""
|
||||
self._only(
|
||||
monkeypatch,
|
||||
commands.Check("postgres", "ok", "fine"),
|
||||
commands.Check("redis", "ok", "fine"),
|
||||
)
|
||||
(tmp_path / ".env").write_text("DOCSGPT_PORT=seven thousand\n", encoding="utf-8")
|
||||
with pytest.raises(DeployError, match="not a port number"):
|
||||
_run(["doctor", "--dir", str(tmp_path)], _context())
|
||||
|
||||
def test_it_says_which_settings_file_it_read(self, tmp_path, capsys, monkeypatch):
|
||||
self._only(
|
||||
monkeypatch,
|
||||
commands.Check("postgres", "ok", "fine"),
|
||||
commands.Check("redis", "ok", "fine"),
|
||||
)
|
||||
(tmp_path / ".env").write_text("LLM_PROVIDER=docsgpt\n", encoding="utf-8")
|
||||
_run(["doctor", "--dir", str(tmp_path)], _context())
|
||||
assert str(tmp_path / ".env") in capsys.readouterr().out
|
||||
+23
-1
@@ -10,6 +10,7 @@ import click
|
||||
import pytest
|
||||
|
||||
from docsgpt import cli
|
||||
from docsgpt.core.paths import package_dir
|
||||
from docsgpt.version import __version__
|
||||
|
||||
|
||||
@@ -126,7 +127,28 @@ class TestApi:
|
||||
uvicorn = types.SimpleNamespace(run=MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", uvicorn)
|
||||
assert cli.main(["api", "--reload", "--host", "127.0.0.1"]) == 0
|
||||
uvicorn.run.assert_called_once_with("docsgpt.asgi:asgi_app", host="127.0.0.1", port=7091, reload=True)
|
||||
uvicorn.run.assert_called_once_with(
|
||||
"docsgpt.asgi:asgi_app", host="127.0.0.1", port=7091, reload=True,
|
||||
reload_dirs=[str(package_dir())],
|
||||
)
|
||||
|
||||
def test_reload_watches_the_package_not_the_working_directory(self, monkeypatch, tmp_path):
|
||||
"""A checkout also holds .venv, node_modules and the indexes and inputs the app writes to,
|
||||
so watching the working directory restarts the server mid-ingest."""
|
||||
uvicorn = types.SimpleNamespace(run=MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", uvicorn)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert cli.main(["api", "--reload"]) == 0
|
||||
watched = uvicorn.run.call_args.kwargs["reload_dirs"]
|
||||
assert watched == [str(package_dir())]
|
||||
assert str(tmp_path) not in watched
|
||||
|
||||
def test_without_reload_nothing_is_watched(self, monkeypatch):
|
||||
uvicorn = types.SimpleNamespace(run=MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", uvicorn)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
assert cli.main(["api"]) == 0
|
||||
assert uvicorn.run.call_args.kwargs["reload_dirs"] is None
|
||||
|
||||
|
||||
class TestWorker:
|
||||
|
||||
Reference in new issue
Block a user