Files
DocsGPT/tests/test_cli.py
T
Alex a96734da07 fix: run the module when the docsgpt command is not on PATH
Refusing to write the service units when `docsgpt` is not on PATH was wrong. A
package installed in a virtualenv is runnable whether or not its console script
is on PATH, and CI runs pytest as `python -m pytest`, where argv[0] is a module
file: the refusal failed thirteen native tests there.

The launcher now prefers the command on PATH, resolved to an absolute path
since PATH can hold relative entries, then an argv[0] that can be executed, and
otherwise this interpreter with `-m docsgpt`, which works wherever the package
is importable. `python -m docsgpt` became an entrypoint of its own and has a
test that runs it.
2026-09-16 22:45:54 +01:00

224 lines
9.9 KiB
Python

"""The ``docsgpt`` command dispatches to the API server, the worker and the scripts."""
import subprocess
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import click
import pytest
from docsgpt import cli
from docsgpt.version import __version__
class TestTopLevel:
def test_version(self, capsys):
with pytest.raises(SystemExit) as exc:
cli.main(["--version"])
assert exc.value.code == 0
assert capsys.readouterr().out.strip() == f"docsgpt {__version__}"
def test_no_command_prints_help(self, capsys):
assert cli.main([]) == 2
assert "worker" in capsys.readouterr().out
def test_importing_the_cli_does_not_boot_the_app(self):
"""``docsgpt --help`` must not import the Flask app, Celery or settings."""
code = (
"import sys, docsgpt.cli; "
"loaded = {m for m in sys.modules if m in ('docsgpt.app', 'docsgpt.core.settings', 'celery', 'flask')}; "
"assert not loaded, loaded"
)
subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1], check=True)
class TestModuleEntrypoint:
def test_python_m_docsgpt_runs_the_cli(self):
"""`python -m docsgpt` is what a native service falls back to when the script is not on PATH."""
result = subprocess.run(
[sys.executable, "-m", "docsgpt", "--version"],
cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, check=True,
)
assert result.stdout.strip() == f"docsgpt {__version__}"
class TestHome:
@staticmethod
def _installed(monkeypatch, tmp_path):
"""An installed package (no checkout) with the default home under tmp_path."""
from docsgpt.core import paths
monkeypatch.delenv(paths.HOME_ENV, raising=False)
monkeypatch.delenv(paths.ENV_FILE_ENV, raising=False)
monkeypatch.setattr(paths, "checkout_root", lambda: None)
monkeypatch.setattr(paths, "default_home", lambda: tmp_path / "home")
return tmp_path / "home"
def test_the_home_is_created_and_announced(self, monkeypatch, tmp_path, capsys):
home = self._installed(monkeypatch, tmp_path)
monkeypatch.chdir(tmp_path)
cli._announce_home()
assert home.is_dir()
assert f"data home {home}" in capsys.readouterr().err
def test_an_env_file_left_in_the_working_directory_is_pointed_out(self, monkeypatch, tmp_path, capsys):
"""Up to 0.20 an installed package read .env from the working directory."""
home = self._installed(monkeypatch, tmp_path)
work = tmp_path / "work"
work.mkdir()
(work / ".env").write_text("LLM_PROVIDER=openai\n")
monkeypatch.chdir(work)
cli._announce_home()
err = capsys.readouterr().err
assert f"{work / '.env'} is not used" in err
assert f"DOCSGPT_HOME={work}" in err
assert str(home) in err
def test_no_warning_when_the_home_is_chosen_explicitly(self, monkeypatch, tmp_path, capsys):
from docsgpt.core import paths
self._installed(monkeypatch, tmp_path)
(tmp_path / ".env").write_text("LLM_PROVIDER=openai\n")
monkeypatch.setenv(paths.HOME_ENV, str(tmp_path / "elsewhere"))
monkeypatch.chdir(tmp_path)
cli._announce_home()
assert "is not used" not in capsys.readouterr().err
def test_no_warning_when_the_working_directory_is_the_home(self, monkeypatch, tmp_path, capsys):
home = self._installed(monkeypatch, tmp_path)
home.mkdir()
(home / ".env").write_text("LLM_PROVIDER=openai\n")
monkeypatch.chdir(home)
cli._announce_home()
assert "is not used" not in capsys.readouterr().err
class TestApi:
def test_gunicorn_runs_with_the_image_settings_and_leaves_argv_alone(self, monkeypatch, capsys):
application = MagicMock()
factory = MagicMock(return_value=application)
monkeypatch.setattr(cli, "_gunicorn_application", factory)
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(sys, "argv", ["/venv/bin/docsgpt", "api", "--port", "8000"])
assert cli.main(["api", "--port", "8000", "--workers", "2"]) == 0
application.run.assert_called_once_with()
options = factory.call_args.args[0]
assert options["bind"] == "127.0.0.1:8000"
assert options["workers"] == 2
assert options["worker_class"] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker"
assert options["max_requests"] == 5000
# gunicorn re-executes sys.argv on SIGUSR2; it must still be the docsgpt invocation.
assert sys.argv == ["/venv/bin/docsgpt", "api", "--port", "8000"]
assert "data home" in capsys.readouterr().err
def test_the_gunicorn_application_carries_the_settings_and_the_log_config(self):
app = cli._gunicorn_application(cli._gunicorn_options("127.0.0.1", 8001, 3))
assert app.cfg.bind == ["127.0.0.1:8001"]
assert app.cfg.workers == 3
assert app.cfg.worker_class_str == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker"
assert app.cfg.keepalive == 5
assert app.cfg.graceful_timeout == 120
assert "ncsa_access" in app.cfg.logconfig_dict["formatters"], "docsgpt.gunicorn_conf was loaded"
def test_reload_uses_uvicorn(self, monkeypatch):
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)
class TestWorker:
@staticmethod
def _celery(monkeypatch, start=None):
celery = MagicMock()
celery.start = start or MagicMock(return_value=0)
monkeypatch.setitem(sys.modules, "docsgpt.app", types.SimpleNamespace(celery=celery))
return celery
def test_defaults_consume_every_configured_queue(self, monkeypatch, capsys):
celery = self._celery(monkeypatch)
monkeypatch.setattr(sys, "platform", "linux")
assert cli.main(["worker"]) == 0
argv = celery.start.call_args.args[0]
assert argv[:3] == ["worker", "-l", "INFO"]
assert "-Q" not in argv, "a bare worker honours EMBEDDINGS_QUEUE and DOCUMENT_PARSE_QUEUE"
assert "-B" in argv
assert "--pool" not in argv
assert "data home" in capsys.readouterr().err
def test_options_and_the_macos_solo_pool(self, monkeypatch):
celery = self._celery(monkeypatch)
monkeypatch.setattr(sys, "platform", "darwin")
assert cli.main(["worker", "--no-beat", "-Q", "embeddings", "--concurrency", "2"]) == 0
argv = celery.start.call_args.args[0]
assert argv[argv.index("--pool") + 1] == "solo"
assert "-B" not in argv
assert argv[argv.index("-Q") + 1] == "embeddings"
assert argv[argv.index("--concurrency") + 1] == "2"
def test_windows_runs_solo_without_the_embedded_scheduler(self, monkeypatch, capsys):
celery = self._celery(monkeypatch)
monkeypatch.setattr(sys, "platform", "win32")
assert cli.main(["worker"]) == 0
argv = celery.start.call_args.args[0]
assert "-B" not in argv
assert argv[argv.index("--pool") + 1] == "solo"
assert "docsgpt beat" in capsys.readouterr().err
def test_the_worker_exit_code_is_returned(self, monkeypatch):
self._celery(monkeypatch, start=MagicMock(return_value=1))
monkeypatch.setattr(sys, "platform", "linux")
assert cli.main(["worker"]) == 1
def test_a_usage_error_prints_usage_instead_of_a_traceback(self, monkeypatch, capsys):
self._celery(monkeypatch, start=MagicMock(side_effect=click.UsageError("No such option: --bogus")))
monkeypatch.setattr(sys, "platform", "linux")
assert cli.main(["worker"]) == 2
assert "No such option" in capsys.readouterr().err
class TestBeat:
def test_runs_the_scheduler_alone(self, monkeypatch):
celery = MagicMock()
celery.start = MagicMock(return_value=0)
monkeypatch.setitem(sys.modules, "docsgpt.app", types.SimpleNamespace(celery=celery))
assert cli.main(["beat", "-l", "DEBUG"]) == 0
assert celery.start.call_args.args[0] == ["beat", "-l", "DEBUG"]
class TestMigrate:
def test_runs_the_bootstrap(self, monkeypatch):
ensure = MagicMock()
monkeypatch.setattr("docsgpt.storage.db.bootstrap.ensure_database_ready", ensure)
monkeypatch.setattr("docsgpt.core.settings.settings.POSTGRES_URI", "postgresql://docsgpt@localhost/docsgpt")
assert cli.main(["migrate", "--no-create"]) == 0
assert ensure.call_args.args[0] == "postgresql://docsgpt@localhost/docsgpt"
assert ensure.call_args.kwargs["create_db"] is False
assert ensure.call_args.kwargs["migrate"] is True
def test_without_a_database_uri(self, monkeypatch, capsys):
monkeypatch.setattr("docsgpt.core.settings.settings.POSTGRES_URI", None)
assert cli.main(["migrate"]) == 2
assert "POSTGRES_URI" in capsys.readouterr().err
class TestScripts:
def test_arguments_pass_through_untouched(self, monkeypatch):
main = MagicMock(return_value=0)
monkeypatch.setattr("docsgpt.scripts.prefetch_models.main", main)
assert cli.main(["prefetch-models", "--embeddings", "x", "--help"]) == 0
main.assert_called_once_with(["--embeddings", "x", "--help"])
def test_the_script_exit_code_is_returned(self, monkeypatch):
monkeypatch.setattr("docsgpt.scripts.verify_offline.main", MagicMock(return_value=3))
assert cli.main(["verify-offline"]) == 3
@pytest.mark.parametrize("script", ["prefetch-models", "verify-offline"])
def test_help_is_help_not_a_model_name(self, script, capsys):
with pytest.raises(SystemExit) as exc:
cli.main([script, "--help"])
assert exc.value.code == 0
assert "models" in capsys.readouterr().out