Skip to content
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,21 @@ Run checks appropriate to the change; use `docs/tests.md` to select focused unit
| Full frontend suite | `npm test` |

Use the **project venv** for backend tests (`.venv/bin/python` on Unix). System Python may lack runtime dependencies and produce misleading failures.

## Environment: uv

`uv` is a supported alternative to `pip` for the project venv; everything works
the same whether the venv was created with `python -m venv` or `uv venv`.

- The backend install paths are uv-aware: `install.sh` uses `uv venv --seed` +
`uv pip install` when `uv` is on PATH (falling back to `python -m venv` +
`pip`), and the in-app updater runs `uv pip install --python <sys.executable>`
when available (`backend/services/git_update.py:dependency_install_command`),
otherwise the venv's own `pip`.
- `--seed` is required when creating a venv at all: `install.sh`, the in-app
updater, and shell workflows that still call `python -m pip` all assume pip
exists in the venv. Recreating with a plain (pip-less) `uv venv` breaks them.
- There is no `pyproject.toml`, so manage dependencies with
`uv pip install -r requirements.txt` — do not use `uv sync`.
- Startup scripts (`mac_linux_start.sh`, `mac_linux_silent_start.sh`) only
execute `.venv/bin/python`, so they are uv-agnostic.
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Then install the Python dependencies **into the venv**:
.venv/bin/python -m pip install -r requirements.txt
```

> **Prefer uv?** `uv venv --seed` (or `--seed --python 3.12`) and
> `uv pip install -r requirements.txt` are drop-in equivalents; the setup,
> `install.sh`, and the in-app updater all detect uv and use it automatically.
> `--seed` matters: the updater and `install.sh` assume pip exists in the venv.

> **Always use the venv Python.** System Python lacks `huggingface_hub` and
> the other runtime dependencies, so tests fail with misleading import
> errors deep inside unrelated modules. Every Python command below shows the
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Special thanks to ggml-org for [llama.cpp](https://github.com/ggml-org/llama.cpp

## Requirements

- Python 3.9+, `pip`, and virtual environment support (`python -m venv`)
- Python 3.9+, `pip`, and virtual environment support (or [uv](https://astral.sh/uv), which the installers and the in-app updater detect and use automatically)
- Internet access for release downloads, optional app updates, and optional Chat web search
- A supported OS/architecture for the prebuilt `llama.cpp` binaries you want

Expand Down Expand Up @@ -90,6 +90,8 @@ Install dependencies:
- macOS/Linux: `./install.sh`
- Windows: `windows_install.bat`

If [uv](https://astral.sh/uv) is on PATH, setup uses it automatically — the venv is created with `uv venv --seed` and requirements are installed with `uv pip install`. The in-app updater prefers uv the same way. The plain `pip` flow remains fully supported.

If macOS/Linux reports `permission denied`, restore the executable bit:

```bash
Expand Down
27 changes: 24 additions & 3 deletions backend/services/git_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import re
import shutil
import subprocess
import sys
from typing import Any
Expand Down Expand Up @@ -231,14 +232,34 @@ def find_latest_release_tag(base_dir, upstream_ref):
return {"tag": "", "error": ""}


def dependency_install_command(requirements_path):
"""Pick uv when available, else the venv's own pip.

``uv pip install --python <sys.executable>`` installs into the interpreter
that is running the app (the project venv), while falling back to pip keeps
plain ``python -m venv`` setups working unchanged.
"""
if shutil.which("uv"):
return [
"uv",
"pip",
"install",
"--python",
sys.executable,
"-r",
str(requirements_path),
]
return [sys.executable, "-m", "pip", "install", "-r", str(requirements_path)]


def install_python_dependencies(ctx: AppContext) -> dict[str, Any]:
requirements_path = ctx.paths.root / "requirements.txt"
if not requirements_path.exists():
return {"installed": False, "message": "requirements.txt was not found."}

try:
res = subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", str(requirements_path)],
dependency_install_command(requirements_path),
cwd=str(ctx.paths.root),
capture_output=True,
text=True,
Expand All @@ -248,14 +269,14 @@ def install_python_dependencies(ctx: AppContext) -> dict[str, Any]:
)
except subprocess.TimeoutExpired:
print(
f"[git_update] pip install timed out after {DEPENDENCY_INSTALL_TIMEOUT_SECONDS}s",
f"[git_update] dependency install timed out after {DEPENDENCY_INSTALL_TIMEOUT_SECONDS}s",
file=sys.stderr,
)
return {"installed": False, "error": "Dependency installation timed out."}
output = (res.stdout or res.stderr or "").strip()
if res.returncode != 0:
print(
f"[git_update] pip install failed: {(res.stderr or res.stdout or '').strip()}",
f"[git_update] dependency install failed: {(res.stderr or res.stdout or '').strip()}",
file=sys.stderr,
)
return {
Expand Down
27 changes: 22 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ if ! "$PY_CMD" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9)
exit 1
fi

if command -v uv >/dev/null 2>&1; then
UV_AVAILABLE=1
else
UV_AVAILABLE=0
fi

if [ ! -d ".venv" ]; then
echo "Creating local virtual environment..."
"$PY_CMD" -m venv .venv
if [ "$UV_AVAILABLE" -eq 1 ]; then
# --seed keeps pip available for the in-app updater and for shell
# workflows that still call "python -m pip".
uv venv --seed --python "$PY_CMD" .venv
else
"$PY_CMD" -m venv .venv
fi
fi

VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
Expand All @@ -43,11 +55,16 @@ if [ "$(uname -s)" = "Linux" ] && ! "$VENV_PYTHON" -c 'import tkinter' >/dev/nul
echo "Llama GUI will still install; add Tk and restart it to enable Browse/Change dialogs."
fi

echo "Upgrading pip..."
"$VENV_PYTHON" -m pip install --upgrade pip
if [ "$UV_AVAILABLE" -eq 1 ]; then
echo "Installing Python dependencies from requirements.txt with uv..."
uv pip install --python "$VENV_PYTHON" -r requirements.txt
else
echo "Upgrading pip..."
"$VENV_PYTHON" -m pip install --upgrade pip

echo "Installing Python dependencies from requirements.txt..."
"$VENV_PYTHON" -m pip install -r requirements.txt
echo "Installing Python dependencies from requirements.txt..."
"$VENV_PYTHON" -m pip install -r requirements.txt
fi

mkdir -p llama/custom/bin llama/custom/grammars
mkdir -p llama/custom-02/bin llama/custom-02/grammars
Expand Down
28 changes: 28 additions & 0 deletions tests/backend/test_extracted_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5744,6 +5744,34 @@ def test_install_deps_subprocess_called(self):
self.assertIn("pip", args)
self.assertIn("install", args)

def test_install_deps_prefers_uv_when_available(self):
(self.ctx.paths.root / "requirements.txt").write_text("requests\n")
with (
mock.patch.object(srv.shutil, "which", return_value="/usr/local/bin/uv"),
mock.patch.object(srv.subprocess, "run") as mock_run,
):
mock_run.return_value = self.proc_result(stdout="Successfully installed")
result = srv.install_python_dependencies(self.ctx)
self.assertTrue(result["installed"])
args = mock_run.call_args[0][0]
self.assertEqual(args[0], "uv")
self.assertIn("pip", args)
self.assertIn("--python", args)

def test_install_deps_falls_back_to_pip_without_uv(self):
(self.ctx.paths.root / "requirements.txt").write_text("requests\n")
with (
mock.patch.object(srv.shutil, "which", return_value=None),
mock.patch.object(srv.subprocess, "run") as mock_run,
):
mock_run.return_value = self.proc_result(stdout="Successfully installed")
result = srv.install_python_dependencies(self.ctx)
self.assertTrue(result["installed"])
args = mock_run.call_args[0][0]
self.assertEqual(args[0], srv.sys.executable)
self.assertIn("-m", args)
self.assertIn("pip", args)

def test_install_deps_subprocess_fails(self):
(self.ctx.paths.root / "requirements.txt").write_text("bad_package\n")
with mock.patch.object(srv.subprocess, "run") as mock_run:
Expand Down
Loading