feat: installed package keeps its data in ~/.docsgpt/server

Outside a checkout the data home was the working directory, so running
`docsgpt api` from another folder silently used different settings and data.
It is now ~/.docsgpt/server (/opt/docsgpt for root on Linux); DOCSGPT_HOME
and a checkout still take precedence. The API and worker commands create
the home and point out a .env left in the working directory.
This commit is contained in:
Alex committed 2026-09-15 22:45:54 +01:00
1 parent 95fd4bdefe
commit ac06527a8d
4 files changed
+106 -11

No files matched your search

+17 -3
View File
@@ -19,10 +19,24 @@ DEFAULT_PORT = 7091
def _announce_home() -> None:
"""Say where runtime data and the env file come from; the API and the worker must agree."""
from docsgpt.core.paths import env_file, home_dir
"""Create the data home and say where data and the env file come from; the API and the worker must agree."""
from pathlib import Path
print(f"docsgpt: data home {home_dir()} (env file {env_file()})", file=sys.stderr)
from docsgpt.core import paths
home = paths.home_dir()
home.mkdir(parents=True, exist_ok=True)
env = paths.env_file()
print(f"docsgpt: data home {home} (env file {env})", file=sys.stderr)
# Up to 0.20 an installed package used the working directory as its home.
chosen = os.environ.get(paths.HOME_ENV) or os.environ.get(paths.ENV_FILE_ENV) or paths.checkout_root()
stray = Path.cwd() / ".env"
if not chosen and stray.is_file() and stray.resolve() != env.resolve():
print(
f"docsgpt: {stray} is not used; settings come from {env}. "
f"Move the file there, or set DOCSGPT_HOME={Path.cwd()} to keep using this directory.",
file=sys.stderr,
)
def _gunicorn_options(host: str, port: int, workers: int) -> dict:
+16 -6
View File
@@ -5,15 +5,18 @@ ships with it (prompts, model catalogs, migrations). A checkout, when the
package is imported from one, is the directory holding ``pyproject.toml``.
The data home is where runtime data lives: ``.env``, ``inputs``, ``indexes``.
The home is ``DOCSGPT_HOME`` when set, else the checkout, else the current
directory. That keeps a source checkout and the Docker image (which runs from
``/app`` with the package beside it) behaving as before, and gives a
``pip install docsgpt`` user a home that is not ``site-packages``.
The home is ``DOCSGPT_HOME`` when set, else the checkout, else the default
home (``~/.docsgpt/server``, or ``/opt/docsgpt`` for root on Linux). That keeps
a source checkout and the Docker image (which pins ``DOCSGPT_HOME=/app``)
behaving as before, and gives an installed package one home no matter which
directory the command runs from. ``docsgpt up`` keeps its stack in the same
place.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
HOME_ENV = "DOCSGPT_HOME"
@@ -31,12 +34,19 @@ def checkout_root() -> Path | None:
return root if (root / "pyproject.toml").is_file() else None
def default_home() -> Path:
"""The home outside a checkout: ``/opt/docsgpt`` for root on Linux, else ``~/.docsgpt/server``."""
if sys.platform.startswith("linux") and hasattr(os, "geteuid") and os.geteuid() == 0:
return Path("/opt/docsgpt")
return Path.home() / ".docsgpt" / "server"
def home_dir() -> Path:
"""Directory for runtime data: ``DOCSGPT_HOME``, else the checkout, else cwd."""
"""Directory for runtime data: ``DOCSGPT_HOME``, else the checkout, else the default home."""
configured = os.environ.get(HOME_ENV)
if configured:
return Path(configured).expanduser().resolve()
return checkout_root() or Path.cwd()
return checkout_root() or default_home()
def env_file() -> Path:
+22 -2
View File
@@ -20,11 +20,31 @@ class TestHomeDir:
monkeypatch.setenv(paths.HOME_ENV, str(tmp_path))
assert paths.home_dir() == tmp_path.resolve()
def test_an_installed_package_falls_back_to_cwd(self, monkeypatch, tmp_path):
def test_an_installed_package_uses_the_default_home_not_cwd(self, monkeypatch, tmp_path):
"""`docsgpt api` from any directory finds the same .env and data."""
monkeypatch.delenv(paths.HOME_ENV, raising=False)
monkeypatch.setattr(paths, "checkout_root", lambda: None)
monkeypatch.setattr(paths, "default_home", lambda: tmp_path / "home")
monkeypatch.chdir(tmp_path)
assert paths.home_dir() == Path.cwd()
assert paths.home_dir() == tmp_path / "home"
class TestDefaultHome:
def test_a_user_gets_a_folder_in_their_home(self, monkeypatch, tmp_path):
monkeypatch.setattr(paths.sys, "platform", "darwin")
monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: tmp_path))
assert paths.default_home() == tmp_path / ".docsgpt" / "server"
def test_root_on_linux_gets_opt(self, monkeypatch):
monkeypatch.setattr(paths.sys, "platform", "linux")
monkeypatch.setattr(paths.os, "geteuid", lambda: 0, raising=False)
assert paths.default_home() == Path("/opt/docsgpt")
def test_a_normal_user_on_linux_stays_in_their_home(self, monkeypatch, tmp_path):
monkeypatch.setattr(paths.sys, "platform", "linux")
monkeypatch.setattr(paths.os, "geteuid", lambda: 1000, raising=False)
monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: tmp_path))
assert paths.default_home() == tmp_path / ".docsgpt" / "server"
class TestEnvFile:
+51
View File
@@ -34,6 +34,57 @@ class TestTopLevel:
subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1], check=True)
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()