From c4a0792c99652ebd5885493f338c8afe8ddc759a Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 07:19:08 -0700 Subject: [PATCH 1/7] fix(proxy): make prisma generate and db push work out-of-the-box after pip install Injects the venv scripts directory into PATH internally so `litellm db generate` and `db push` resolve the prisma binary without manual PATH setup after a plain pip install. Adds a `db generate` CLI command, PATH-injection in proxy-extras utils, unit tests for the CLI and path verifier, and a gated integration test (`integration` marker + LITELLM_RUN_INTEGRATION_TESTS) so the real 244s prisma generate does not run in the standard unit shard. --- .../litellm_proxy_extras/utils.py | 32 ++- .../tests/test_prisma_env_path_injection.py | 152 ++++++++++++ litellm/proxy/client/cli/commands/db.py | 108 ++++++++ litellm/proxy/client/cli/main.py | 3 + pyproject.toml | 1 + tests/proxy_cli_integration_tests/README.md | 33 +++ .../test_db_generate_integration.py | 96 +++++++ .../proxy/client/cli/test_db_commands.py | 234 ++++++++++++++++++ .../cli/test_db_generate_path_verifier.py | 193 +++++++++++++++ 9 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 litellm-proxy-extras/tests/test_prisma_env_path_injection.py create mode 100644 litellm/proxy/client/cli/commands/db.py create mode 100644 tests/proxy_cli_integration_tests/README.md create mode 100644 tests/proxy_cli_integration_tests/test_db_generate_integration.py create mode 100644 tests/test_litellm/proxy/client/cli/test_db_commands.py create mode 100644 tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index 369b6561931..a89ac431ea5 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -4,6 +4,8 @@ import re import shutil import subprocess +import sys +import sysconfig import tempfile import time from pathlib import Path @@ -19,7 +21,15 @@ def str_to_bool(value: Optional[str]) -> bool: def _get_prisma_env() -> dict: - """Get environment variables for Prisma, handling offline mode if configured.""" + """Get environment variables for Prisma, handling offline mode if configured. + + Also injects the running interpreter's scripts directory (the venv ``bin/`` + dir) to the front of PATH so the ``prisma-client-py`` console-script is + found by ``/bin/sh`` without needing to activate the venv. This fixes the + silent failure where ``prisma db push`` and ``prisma migrate deploy`` exit 0 + but never create tables when invoked by absolute path outside an active venv + (Symptom 3 of issue #26097). + """ prisma_env = os.environ.copy() if str_to_bool(os.getenv("PRISMA_OFFLINE_MODE")): # These env vars prevent Prisma from attempting downloads @@ -27,6 +37,25 @@ def _get_prisma_env() -> dict: prisma_env["NPM_CONFIG_CACHE"] = os.getenv( "NPM_CONFIG_CACHE", "/app/.cache/npm" ) + + # Inject the venv scripts dir so prisma-client-py resolves without + # venv activation. Needed for db push and migrate deploy just as much + # as for db generate — all three shell out via /bin/sh which only finds + # the binary if it is on PATH. + scripts_dir = sysconfig.get_path("scripts") + if not scripts_dir: + scripts_dir = os.path.dirname(os.path.abspath(sys.executable)) + if scripts_dir: + existing_path = prisma_env.get("PATH", "") + path_entries = existing_path.split(os.pathsep) if existing_path else [] + if scripts_dir not in path_entries or path_entries[0] != scripts_dir: + # Remove any existing occurrence and place scripts_dir at index 0. + filtered = [p for p in path_entries if p != scripts_dir] + remaining = os.pathsep.join(filtered) + prisma_env["PATH"] = ( + scripts_dir + os.pathsep + remaining if remaining else scripts_dir + ) + return prisma_env @@ -924,6 +953,7 @@ def setup_database( [_get_prisma_command(), "db", "push", "--accept-data-loss"], timeout=60, check=True, + env=_get_prisma_env(), ) return True except subprocess.TimeoutExpired: diff --git a/litellm-proxy-extras/tests/test_prisma_env_path_injection.py b/litellm-proxy-extras/tests/test_prisma_env_path_injection.py new file mode 100644 index 00000000000..2be045f6156 --- /dev/null +++ b/litellm-proxy-extras/tests/test_prisma_env_path_injection.py @@ -0,0 +1,152 @@ +"""Tests for _get_prisma_env() venv PATH injection (Symptom 3 of issue #26097). + +Symptom 3: after `pip install 'litellm[proxy]'`, running the proxy by absolute +path in a non-activated venv causes `prisma db push` to exit 0 without creating +tables because `prisma-client-py` is not on PATH when /bin/sh forks for the +generator. The fix: _get_prisma_env() prepends the interpreter's scripts dir. +""" + +import os +from unittest.mock import MagicMock, patch + +from litellm_proxy_extras.utils import ProxyExtrasDBManager, _get_prisma_env + + +# --------------------------------------------------------------------------- +# Unit tests for _get_prisma_env() PATH injection +# --------------------------------------------------------------------------- + + +def test_get_prisma_env_injects_scripts_dir_at_index_0(monkeypatch): + """Scripts dir appears at PATH index 0 when venv is not activated.""" + fake_scripts = "/fake/venv/bin" + # Remove scripts dir from PATH so it's not already there + stripped_path = "/usr/local/bin:/usr/bin:/bin" + monkeypatch.setenv("PATH", stripped_path) + with ( + patch("sysconfig.get_path", return_value=fake_scripts), + ): + env = _get_prisma_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == fake_scripts, ( + f"Expected scripts dir at index 0, got: {env['PATH']}" + ) + # Original PATH entries preserved after the injected dir + assert "/usr/local/bin" in path_entries + assert "/usr/bin" in path_entries + + +def test_get_prisma_env_promotes_scripts_dir_to_index_0(monkeypatch): + """Scripts dir is promoted to index 0 if already in PATH at a later position.""" + fake_scripts = "/fake/venv/bin" + path_with_scripts_at_end = f"/usr/bin:/bin:{fake_scripts}" + monkeypatch.setenv("PATH", path_with_scripts_at_end) + with patch("sysconfig.get_path", return_value=fake_scripts): + env = _get_prisma_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == fake_scripts + # Must appear exactly once + assert path_entries.count(fake_scripts) == 1 + + +def test_get_prisma_env_handles_empty_path(monkeypatch): + """Empty PATH results in PATH containing only the scripts dir.""" + fake_scripts = "/fake/venv/bin" + monkeypatch.setenv("PATH", "") + with patch("sysconfig.get_path", return_value=fake_scripts): + env = _get_prisma_env() + + assert env["PATH"] == fake_scripts + assert os.pathsep not in env["PATH"] or env["PATH"].rstrip(os.pathsep) == fake_scripts + + +def test_get_prisma_env_falls_back_to_dirname_when_sysconfig_falsy(monkeypatch): + """Falls back to dirname(sys.executable) when sysconfig returns falsy.""" + fake_exe = "/fake/venv/bin/python3" + monkeypatch.setenv("PATH", "/usr/bin") + with ( + patch("sysconfig.get_path", return_value=None), + patch("sys.executable", fake_exe), + ): + env = _get_prisma_env() + + expected_scripts = os.path.dirname(os.path.abspath(fake_exe)) + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == expected_scripts + + +def test_get_prisma_env_sets_offline_vars_when_enabled(monkeypatch): + """PRISMA_OFFLINE_MODE vars are still set alongside the PATH injection.""" + monkeypatch.setenv("PRISMA_OFFLINE_MODE", "true") + monkeypatch.setenv("PATH", "/usr/bin") + with patch("sysconfig.get_path", return_value="/venv/bin"): + env = _get_prisma_env() + + assert env.get("NPM_CONFIG_PREFER_OFFLINE") == "true" + assert "NPM_CONFIG_CACHE" in env + # PATH injection still happens + assert env["PATH"].startswith("/venv/bin") + + +def test_get_prisma_env_noop_when_scripts_dir_already_at_index_0(monkeypatch): + """No duplicate entry when scripts dir is already at index 0.""" + fake_scripts = "/fake/venv/bin" + monkeypatch.setenv("PATH", f"{fake_scripts}:/usr/bin:/bin") + with patch("sysconfig.get_path", return_value=fake_scripts): + env = _get_prisma_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == fake_scripts + assert path_entries.count(fake_scripts) == 1 + + +# --------------------------------------------------------------------------- +# Integration: db push subprocess receives patched env with scripts_dir +# --------------------------------------------------------------------------- + + +def test_db_push_subprocess_receives_scripts_dir_on_path(monkeypatch, tmp_path): + """setup_database(use_migrate=False) passes env with scripts_dir to subprocess. + + This is the Symptom 3 integration guard: the subprocess call must receive + the patched env dict that has the venv scripts dir at PATH index 0. Without + it, prisma db push exits 0 but the generator fails silently and tables are + never created. + """ + fake_scripts = "/fake/venv/bin" + monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/testdb") + + (tmp_path / "schema.prisma").write_text("// stub schema\n") + + captured_calls = [] + + def fake_subprocess_run(args, **kwargs): + captured_calls.append({"args": list(args), "env": kwargs.get("env", {})}) + return MagicMock(returncode=0) + + with ( + patch("sysconfig.get_path", return_value=fake_scripts), + patch.object(ProxyExtrasDBManager, "_get_prisma_dir", return_value=str(tmp_path)), + patch("litellm_proxy_extras.utils._get_prisma_command", return_value="prisma"), + patch("subprocess.run", side_effect=fake_subprocess_run), + ): + result = ProxyExtrasDBManager.setup_database(use_migrate=False) + + assert result is True, "setup_database should return True on success" + assert len(captured_calls) == 1, f"Expected 1 subprocess call, got {len(captured_calls)}" + + received_env = captured_calls[0]["env"] + assert received_env is not None, "subprocess.run must be called with env=" + path_entries = received_env.get("PATH", "").split(os.pathsep) + assert path_entries[0] == fake_scripts, ( + f"Expected scripts dir '{fake_scripts}' at PATH index 0 in subprocess env. " + f"Got PATH: {received_env.get('PATH')}" + ) + + call_args = captured_calls[0]["args"] + assert "db" in call_args + assert "push" in call_args + assert "--accept-data-loss" in call_args diff --git a/litellm/proxy/client/cli/commands/db.py b/litellm/proxy/client/cli/commands/db.py new file mode 100644 index 00000000000..ef8068edb98 --- /dev/null +++ b/litellm/proxy/client/cli/commands/db.py @@ -0,0 +1,108 @@ +"""Database management commands for the LiteLLM proxy CLI.""" + +import os +import subprocess +import sys +import sysconfig + +import click + +# Import Prisma helpers from litellm-proxy-extras at module level. +# These are optional dependencies; the ImportError is handled at call time. +try: + from litellm_proxy_extras.utils import ( + ProxyExtrasDBManager, + _get_prisma_command, + _get_prisma_env, + ) + + _PROXY_EXTRAS_AVAILABLE = True +except ImportError: + _PROXY_EXTRAS_AVAILABLE = False + ProxyExtrasDBManager = None # type: ignore[assignment] + _get_prisma_command = None # type: ignore[assignment] + _get_prisma_env = None # type: ignore[assignment] + + +def _get_venv_scripts_dir() -> str: + """Return the directory that holds console scripts for the running interpreter. + + The prisma engine shells out to the ``prisma-client-py`` console script via + ``/bin/sh``, which only finds it when that directory is on PATH. In an + *activated* venv it is; when ``litellm-proxy`` is invoked by absolute path + without activation, it is not. We derive the directory from the current + interpreter so the caller never has to activate the venv. + + ``sysconfig.get_path("scripts")`` is the authoritative answer; we fall back + to ``dirname(sys.executable)`` if it is empty (defensive: should not happen + on a normal CPython install). + """ + scripts_dir = sysconfig.get_path("scripts") + if not scripts_dir: + scripts_dir = os.path.dirname(os.path.abspath(sys.executable)) + return scripts_dir + + +def _get_generate_env() -> dict: + """Build the subprocess env for ``db generate``. + + Delegates to the shared ``_get_prisma_env()`` which now injects the venv + scripts dir for all Prisma subprocesses (generate, db push, migrate deploy). + Kept as a named wrapper for backward-compat with existing tests. + """ + if _get_prisma_env: + result = _get_prisma_env() + if result is not None: + return result + return os.environ.copy() + + +@click.group() +def db() -> None: + """Database management commands.""" + + +@db.command(name="generate") +def db_generate() -> None: + """Generate the Prisma client using the schema bundled with litellm-proxy-extras. + + Runs: prisma generate --schema + + I resolve the schema path from the installed litellm-proxy-extras package, + so you never need to know internal site-packages paths. This fixes the gap + where `migrate deploy` and `db push` already use the bundled schema but + there was no equivalent for the generate step. + """ + if not _PROXY_EXTRAS_AVAILABLE: + click.echo( + "Error: litellm-proxy-extras is not installed. " + "Run: pip install 'litellm[proxy]'", + err=True, + ) + raise SystemExit(1) + + prisma_dir = ProxyExtrasDBManager._get_prisma_dir() + schema_path = os.path.join(prisma_dir, "schema.prisma") + + if not os.path.exists(schema_path): + click.echo( + f"Error: schema.prisma not found at {schema_path}. " + "Your litellm-proxy-extras installation may be incomplete.", + err=True, + ) + raise SystemExit(1) + + click.echo(f"Generating Prisma client from {schema_path} ...") + try: + subprocess.run( + [_get_prisma_command(), "generate", "--schema", schema_path], + check=True, + env=_get_generate_env(), + ) + click.echo("Prisma client generated successfully.") + except subprocess.CalledProcessError as e: + click.echo( + f"Error: prisma generate failed (exit {e.returncode}).", + err=True, + ) + raise SystemExit(e.returncode) diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index b8c483f4b08..398db44bb7c 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -11,6 +11,7 @@ from .commands.auth import get_stored_api_key, login, logout, whoami from .commands.chat import chat from .commands.credentials import credentials +from .commands.db import db from .commands.http import http from .commands.keys import keys @@ -99,6 +100,8 @@ def version(ctx: click.Context): cli.add_command(login) cli.add_command(logout) cli.add_command(whoami) +# Add the db command group +cli.add_command(db) # Add the models command group cli.add_command(models) # Add the credentials command group diff --git a/pyproject.toml b/pyproject.toml index 8b1386aaf87..7e2883a1fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -286,6 +286,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" markers = [ "asyncio: mark test as an asyncio test", + "integration: mark test as an integration test requiring real binaries/processes", "limit_leaks: mark test with memory limit for leak detection (e.g., '40 MB')", "no_parallel: mark test to run sequentially (not in parallel) - typically for memory measurement tests", ] diff --git a/tests/proxy_cli_integration_tests/README.md b/tests/proxy_cli_integration_tests/README.md new file mode 100644 index 00000000000..053abbc3419 --- /dev/null +++ b/tests/proxy_cli_integration_tests/README.md @@ -0,0 +1,33 @@ +# Proxy CLI integration tests + +These tests invoke real binaries and subprocesses (for example the installed +`litellm-proxy` console script) rather than mocks. They live here, outside +`tests/test_litellm/`, because that tree is reserved for mock-only unit tests +that must run deterministically in CI without external binaries. + +## Running + +They are skipped by default. Opt in with an environment variable: + +```bash +LITELLM_RUN_INTEGRATION_TESTS=1 pytest tests/proxy_cli_integration_tests/ +``` + +You also need the package installed so the console scripts exist on PATH: + +```bash +pip install -e '.[proxy]' +``` + +Individual tests additionally `pytest.skip` themselves when a required binary +(such as `litellm-proxy`) is not present in the active environment, so a partial +install degrades to a skip rather than a failure. + +## Markers + +Tests here are tagged with the `integration` marker (registered in +`pyproject.toml`). To run only integration-marked tests: + +```bash +LITELLM_RUN_INTEGRATION_TESTS=1 pytest -m integration +``` diff --git a/tests/proxy_cli_integration_tests/test_db_generate_integration.py b/tests/proxy_cli_integration_tests/test_db_generate_integration.py new file mode 100644 index 00000000000..aa86e582e91 --- /dev/null +++ b/tests/proxy_cli_integration_tests/test_db_generate_integration.py @@ -0,0 +1,96 @@ +"""Integration tests for `litellm-proxy db generate` (Symptom 2). + +Verifies that invoking the binary by absolute path, without activating the venv, +still finds the bundled `prisma` script because `_get_generate_env()` prepends +the interpreter's scripts directory to PATH. + +These tests invoke real binaries, so they live outside `tests/test_litellm/` +(the mock-only unit tree) and are skipped by default. Set +`LITELLM_RUN_INTEGRATION_TESTS=1` to run them. See the README in this directory +for details. +""" + +import os +import stat +import subprocess +import sysconfig + +import pytest + + +@pytest.fixture +def non_activated_env(): + venv_root = sysconfig.get_path("data") or os.path.dirname(sysconfig.get_path("scripts") or "") + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + env.pop("VIRTUAL_ENV_PROMPT", None) + path_entries = env.get("PATH", "").split(os.pathsep) + env["PATH"] = os.pathsep.join( + e for e in path_entries if not e.startswith(venv_root) + ) + return env + + +@pytest.fixture +def fake_prisma_bin(tmp_path): + fake_bin = tmp_path / "fake_bin" + fake_bin.mkdir() + prisma = fake_bin / "prisma" + prisma.write_text("#!/bin/sh\necho \"fake-prisma called: $@\"\nexit 0\n") + prisma.chmod(prisma.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return fake_bin + + +@pytest.mark.integration +@pytest.mark.skipif( + not os.environ.get("LITELLM_RUN_INTEGRATION_TESTS"), + reason="set LITELLM_RUN_INTEGRATION_TESTS=1 to run integration tests", +) +def test_db_generate_absolute_path_non_activated_venv(non_activated_env): + scripts_dir = sysconfig.get_path("scripts") + litellm_proxy_bin = os.path.join(scripts_dir, "litellm-proxy") + if not os.path.isfile(litellm_proxy_bin): + pytest.skip("litellm-proxy console script not installed in venv") + + result = subprocess.run( + [litellm_proxy_bin, "db", "generate"], + env=non_activated_env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + assert "Generating Prisma client" in result.stdout + assert "Prisma client generated successfully." in result.stdout + + +@pytest.mark.integration +@pytest.mark.skipif( + not os.environ.get("LITELLM_RUN_INTEGRATION_TESTS"), + reason="set LITELLM_RUN_INTEGRATION_TESTS=1 to run integration tests", +) +def test_db_generate_succeeds_with_scripts_dir_off_caller_path(non_activated_env): + """The fix injects the interpreter scripts dir into PATH internally, so + ``db generate`` finds the bundled ``prisma`` script even when the caller's + PATH does not contain the venv scripts dir. This is the positive proof of + Symptom 2: a stripped caller PATH no longer breaks generation. + """ + scripts_dir = sysconfig.get_path("scripts") + litellm_proxy_bin = os.path.join(scripts_dir, "litellm-proxy") + if not os.path.isfile(litellm_proxy_bin): + pytest.skip("litellm-proxy console script not installed in venv") + + # Caller PATH deliberately excludes the venv scripts dir (and thus prisma). + test_env = non_activated_env.copy() + test_env["PATH"] = "/usr/bin:/bin" + + result = subprocess.run( + [litellm_proxy_bin, "db", "generate"], + env=test_env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + assert "Generating Prisma client" in result.stdout + assert "Prisma client generated successfully." in result.stdout diff --git a/tests/test_litellm/proxy/client/cli/test_db_commands.py b/tests/test_litellm/proxy/client/cli/test_db_commands.py new file mode 100644 index 00000000000..9b5cfc621a8 --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_db_commands.py @@ -0,0 +1,234 @@ +# stdlib imports +import os +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# third party imports +from click.testing import CliRunner + +sys.path.insert(0, os.path.abspath("../../..")) + +# local imports +from litellm.proxy.client.cli import cli + + +@pytest.fixture +def cli_runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def mock_env(): + with patch.dict( + os.environ, + { + "LITELLM_PROXY_URL": "http://localhost:4000", + "LITELLM_PROXY_API_KEY": "sk-test", + }, + ): + yield + + +def test_db_generate_success(cli_runner): + mock_run = MagicMock(return_value=MagicMock(returncode=0)) + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/fake/prisma/dir", + ), + patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), + patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), + patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code == 0, result.output + call_args = mock_run.call_args[0][0] + assert "prisma" in call_args + assert "generate" in call_args + assert "--schema" in call_args + schema_arg = call_args[call_args.index("--schema") + 1] + assert schema_arg.endswith("schema.prisma") + assert "Prisma client generated successfully." in result.output + + +def test_db_generate_schema_path_uses_get_prisma_dir(cli_runner): + mock_run = MagicMock(return_value=MagicMock(returncode=0)) + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/custom/extras/dir", + ), + patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), + patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), + patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code == 0, result.output + call_args = mock_run.call_args[0][0] + schema_arg = call_args[call_args.index("--schema") + 1] + assert schema_arg == "/custom/extras/dir/schema.prisma" + + +def test_db_generate_prisma_failure(cli_runner): + mock_run = MagicMock(side_effect=subprocess.CalledProcessError(1, "prisma")) + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/fake/prisma/dir", + ), + patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), + patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), + patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code != 0 + assert "prisma generate failed" in result.output + + +def test_db_generate_schema_missing(cli_runner): + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/fake/prisma/dir", + ), + patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), + patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=False), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code != 0 + assert "schema.prisma not found" in result.output + + +def test_db_generate_proxy_extras_not_installed(cli_runner): + with patch.dict( + sys.modules, + { + "litellm_proxy_extras": None, + "litellm_proxy_extras.utils": None, + }, + ): + # Patch the module-level flag directly since the import already happened + with patch("litellm.proxy.client.cli.commands.db._PROXY_EXTRAS_AVAILABLE", False): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code != 0 + assert "not installed" in result.output + + +def test_db_group_registered_in_cli(cli_runner): + result = cli_runner.invoke(cli, ["db", "--help"]) + assert result.exit_code == 0 + assert "generate" in result.output + + +def test_db_generate_help(cli_runner): + result = cli_runner.invoke(cli, ["db", "generate", "--help"]) + assert result.exit_code == 0 + assert any(word in result.output for word in ("Prisma", "schema", "prisma")) + + +def test_db_generate_env_includes_scripts_dir_on_path(cli_runner): + """The generate subprocess env must carry the interpreter's scripts dir on + PATH so the bundled `prisma-client-py` generator resolves without the caller + activating the venv (Symptoms 2 & 3). + + PATH injection is now the responsibility of _get_prisma_env() (not + _get_generate_env()), so this test mocks _get_prisma_env to return a + realistic env that already has scripts_dir prepended — matching what the + real implementation produces — and verifies the env reaches subprocess.run + intact.""" + import sysconfig + + captured = {} + + def _capture(*args, **kwargs): + captured["env"] = kwargs.get("env") + return MagicMock(returncode=0) + + scripts_dir = sysconfig.get_path("scripts") or os.path.dirname( + os.path.abspath(sys.executable) + ) + + # Simulate what _get_prisma_env() actually returns: scripts_dir prepended, + # base system PATH entries preserved after it. + injected_env = {"PATH": os.pathsep.join([scripts_dir, "/usr/bin", "/bin"])} + + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/fake/prisma/dir", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + # _get_prisma_env already injects scripts_dir — mock returns realistic output + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", + return_value=injected_env, + ), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), + patch("litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code == 0, result.output + env = captured["env"] + assert env is not None, "subprocess.run was called without an explicit env" + path_entries = env["PATH"].split(os.pathsep) + assert ( + scripts_dir in path_entries + ), f"scripts dir {scripts_dir!r} not on child PATH {env['PATH']!r}" + # scripts_dir must be first so it shadows any system-installed prisma binary + assert path_entries[0] == scripts_dir + # base env entries are preserved, not clobbered + assert "/usr/bin" in path_entries + + +def test_db_generate_env_does_not_duplicate_scripts_dir(cli_runner): + """If the scripts dir is already on PATH (e.g. an activated venv), the fix + must not add a duplicate entry.""" + import sysconfig + + captured = {} + + def _capture(*args, **kwargs): + captured["env"] = kwargs.get("env") + return MagicMock(returncode=0) + + scripts_dir = sysconfig.get_path("scripts") or os.path.dirname( + os.path.abspath(sys.executable) + ) + + with ( + patch( + "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + return_value="/fake/prisma/dir", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", + return_value={"PATH": scripts_dir + os.pathsep + "/usr/bin"}, + ), + patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), + patch("litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture), + ): + result = cli_runner.invoke(cli, ["db", "generate"]) + + assert result.exit_code == 0, result.output + path_entries = captured["env"]["PATH"].split(os.pathsep) + assert path_entries.count(scripts_dir) == 1 diff --git a/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py b/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py new file mode 100644 index 00000000000..42173788aaf --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py @@ -0,0 +1,193 @@ +# Adversarial verifier tests for litellm-prisma-generate-oob (venv PATH fix) +import os +import shutil +import stat +import tempfile +from unittest.mock import patch + +import pytest + +from litellm.proxy.client.cli.commands.db import _get_generate_env, _get_venv_scripts_dir + +_DB_MODULE = "litellm.proxy.client.cli.commands.db" + + +# --------------------------------------------------------------------------- +# _get_venv_scripts_dir unit tests +# --------------------------------------------------------------------------- + + +def test_get_venv_scripts_dir_returns_sysconfig_path(): + with patch("sysconfig.get_path", return_value="/home/user/venv/bin") as mock_get: + result = _get_venv_scripts_dir() + mock_get.assert_called_once_with("scripts") + assert result == "/home/user/venv/bin" + + +@pytest.mark.parametrize("falsy_value", [None, ""]) +def test_get_venv_scripts_dir_falls_back_when_sysconfig_falsy(falsy_value): + fake_exe = "/home/user/venv/bin/python" + with ( + patch("sysconfig.get_path", return_value=falsy_value), + patch("sys.executable", fake_exe), + ): + result = _get_venv_scripts_dir() + assert result == os.path.dirname(os.path.abspath(fake_exe)) + + +# --------------------------------------------------------------------------- +# _get_generate_env unit tests +# --------------------------------------------------------------------------- + + +def test_get_generate_env_no_path_key_in_base_env(): + fake_scripts = "/home/user/venv/bin" + base_env_with_path = { + "SOME_VAR": "value", + "PATH": fake_scripts, + } + with patch(f"{_DB_MODULE}._get_prisma_env", return_value=base_env_with_path): + env = _get_generate_env() + assert env["PATH"] == fake_scripts + assert not env["PATH"].startswith(os.pathsep) + + +def test_get_generate_env_prisma_env_attribute_is_none(): + with ( + patch(f"{_DB_MODULE}._get_prisma_env", None), + patch.dict(os.environ, {"MY_CUSTOM_VAR": "hello"}, clear=False), + ): + env = _get_generate_env() + assert env.get("MY_CUSTOM_VAR") == "hello" + assert isinstance(env, dict) + + +def test_get_generate_env_prisma_env_callable_returns_none(): + sentinel_var = "_VERIFIER_TEST_SENTINEL_12345" + with ( + patch(f"{_DB_MODULE}._get_prisma_env", return_value=None), + patch.dict(os.environ, {sentinel_var: "sentinel_value"}, clear=False), + ): + env = _get_generate_env() + assert env.get(sentinel_var) == "sentinel_value" + + +def test_get_generate_env_preserves_non_path_env_vars(): + base_env = { + "PATH": "/usr/bin:/bin", + "DATABASE_URL": "postgres://localhost/db", + "NODE_OPTIONS": "--max-old-space-size=512", + "NPM_CONFIG_CACHE": "/tmp/npm", + } + fake_scripts = "/home/user/venv/bin" + with ( + patch(f"{_DB_MODULE}._get_prisma_env", return_value=base_env), + patch(f"{_DB_MODULE}._get_venv_scripts_dir", return_value=fake_scripts), + ): + env = _get_generate_env() + assert env["DATABASE_URL"] == "postgres://localhost/db" + assert env["NODE_OPTIONS"] == "--max-old-space-size=512" + assert env["NPM_CONFIG_CACHE"] == "/tmp/npm" + + +# --------------------------------------------------------------------------- +# Entrypoint resolvability test +# --------------------------------------------------------------------------- + + +def test_prisma_client_py_resolvable_via_path(): + with tempfile.TemporaryDirectory() as tmpdir: + fake_exe = os.path.join(tmpdir, "prisma-client-py") + with open(fake_exe, "w") as f: + f.write("#!/bin/sh\necho fake\n") + os.chmod(fake_exe, os.stat(fake_exe).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + with patch(f"{_DB_MODULE}._get_prisma_env", return_value={"PATH": f"{tmpdir}:/usr/bin"}): + env = _get_generate_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == tmpdir, ( + f"scripts dir must be first on PATH; got {path_entries!r}" + ) + found = shutil.which("prisma-client-py", path=env["PATH"]) + assert found is not None, "prisma-client-py not findable via PATH lookup" + assert os.path.dirname(found) == tmpdir + + +# --------------------------------------------------------------------------- +# Adversarial: scripts_dir already at non-zero index +# +# _get_prisma_env() must promote scripts_dir to index 0 even when it appears +# later in PATH. _get_generate_env() delegates entirely to _get_prisma_env(), +# so this test verifies the delegation chain preserves the promotion. +# --------------------------------------------------------------------------- + + +def test_scripts_dir_promoted_to_index_zero_when_at_later_position(): + fake_scripts = "/home/user/venv/bin" + promoted_path = os.pathsep.join([ + fake_scripts, + "/usr/bin", + "/opt/other", + "/bin", + ]) + promoted_env = {"PATH": promoted_path} + + with patch(f"{_DB_MODULE}._get_prisma_env", return_value=promoted_env): + env = _get_generate_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == fake_scripts, ( + f"scripts_dir must be at index 0 in the env returned by _get_generate_env(); " + f"got {path_entries!r}. " + f"_get_prisma_env() is responsible for the promotion; " + f"_get_generate_env() must faithfully delegate." + ) + assert path_entries.count(fake_scripts) == 1, ( + "scripts_dir must not be duplicated" + ) + + +# --------------------------------------------------------------------------- +# Delegation regression tests (added for Symptom 3 consolidation) +# After _get_prisma_env() absorbed PATH injection, _get_generate_env() is a +# thin delegation wrapper. These tests ensure the delegation chain works end- +# to-end and the None-guard fires correctly when extras are not installed. +# --------------------------------------------------------------------------- + + +def test_get_generate_env_delegation_chain_returns_scripts_dir_at_index_0(): + """End-to-end: _get_generate_env() produces PATH with scripts_dir at index 0. + + Validates the full delegation path: _get_generate_env() -> _get_prisma_env() + (which now handles PATH injection). This is the regression guard for the + Symptom 3 consolidation: if the delegation chain breaks, db generate would + regress to the old scoped-only fix and db push would still be unfixed. + """ + fake_scripts = "/fake/venv/bin" + base_env = {"PATH": "/usr/bin:/bin"} + with ( + patch(f"{_DB_MODULE}._get_prisma_env", return_value={**base_env, "PATH": f"{fake_scripts}:/usr/bin:/bin"}), + ): + env = _get_generate_env() + + path_entries = env["PATH"].split(os.pathsep) + assert path_entries[0] == fake_scripts, ( + f"_get_generate_env() must return env with scripts_dir at PATH index 0. " + f"Got: {env.get('PATH')}" + ) + + +def test_get_generate_env_falls_back_to_os_environ_when_prisma_env_is_none(): + """Falls back to os.environ.copy() when _get_prisma_env is None (extras not installed). + + In the not-installed case, _PROXY_EXTRAS_AVAILABLE=False and _get_prisma_env + is bound to None (not a callable). The wrapper must not raise AttributeError. + """ + with patch(f"{_DB_MODULE}._get_prisma_env", None): + env = _get_generate_env() + + # Must return a dict (a copy of os.environ) without raising + assert isinstance(env, dict) + assert len(env) > 0 + From 83e6c2853130a3791dd260e11395d08180e686d8 Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 07:34:53 -0700 Subject: [PATCH 2/7] fix(proxy): resolve db generate schema from litellm package, not proxy-extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback (jamesmyatt): db generate now resolves schema.prisma via PrismaManager (litellm/proxy/schema.prisma) — the same schema db push uses — instead of reaching into the sibling litellm-proxy-extras package. Keeps the generate path consistent with the existing push path and corrects the docstring, which wrongly claimed db push uses the proxy-extras schema. proxy-extras is still imported for prisma-binary discovery and PATH injection (not the schema). --- litellm/proxy/client/cli/commands/db.py | 24 +++++++++++-------- .../proxy/client/cli/test_db_commands.py | 16 ++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/client/cli/commands/db.py b/litellm/proxy/client/cli/commands/db.py index ef8068edb98..ccc7715cdee 100644 --- a/litellm/proxy/client/cli/commands/db.py +++ b/litellm/proxy/client/cli/commands/db.py @@ -7,11 +7,16 @@ import click -# Import Prisma helpers from litellm-proxy-extras at module level. -# These are optional dependencies; the ImportError is handled at call time. +# The Prisma schema ships inside the litellm package itself (litellm/proxy/ +# schema.prisma); PrismaManager resolves that directory. This is the same +# schema `db push` operates on, so `db generate` stays consistent with it. +from litellm.proxy.db.prisma_client import PrismaManager + +# The prisma *binary* discovery and PATH-injection helpers live in +# litellm-proxy-extras. These are optional dependencies; the ImportError is +# handled at call time so `litellm` works without the proxy extras installed. try: from litellm_proxy_extras.utils import ( - ProxyExtrasDBManager, _get_prisma_command, _get_prisma_env, ) @@ -19,7 +24,6 @@ _PROXY_EXTRAS_AVAILABLE = True except ImportError: _PROXY_EXTRAS_AVAILABLE = False - ProxyExtrasDBManager = None # type: ignore[assignment] _get_prisma_command = None # type: ignore[assignment] _get_prisma_env = None # type: ignore[assignment] @@ -64,14 +68,14 @@ def db() -> None: @db.command(name="generate") def db_generate() -> None: - """Generate the Prisma client using the schema bundled with litellm-proxy-extras. + """Generate the Prisma client using litellm's bundled schema. Runs: prisma generate --schema - I resolve the schema path from the installed litellm-proxy-extras package, - so you never need to know internal site-packages paths. This fixes the gap - where `migrate deploy` and `db push` already use the bundled schema but - there was no equivalent for the generate step. + The schema is resolved from the litellm package (litellm/proxy/schema.prisma) + via PrismaManager, which is the same schema `db push` operates on. This keeps + generate consistent with the existing push path and closes the gap where there + was no out-of-the-box `generate` step after a plain pip install. """ if not _PROXY_EXTRAS_AVAILABLE: click.echo( @@ -81,7 +85,7 @@ def db_generate() -> None: ) raise SystemExit(1) - prisma_dir = ProxyExtrasDBManager._get_prisma_dir() + prisma_dir = PrismaManager._get_prisma_dir() schema_path = os.path.join(prisma_dir, "schema.prisma") if not os.path.exists(schema_path): diff --git a/tests/test_litellm/proxy/client/cli/test_db_commands.py b/tests/test_litellm/proxy/client/cli/test_db_commands.py index 9b5cfc621a8..16ba30da2e5 100644 --- a/tests/test_litellm/proxy/client/cli/test_db_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_db_commands.py @@ -36,7 +36,7 @@ def test_db_generate_success(cli_runner): mock_run = MagicMock(return_value=MagicMock(returncode=0)) with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), @@ -60,8 +60,8 @@ def test_db_generate_schema_path_uses_get_prisma_dir(cli_runner): mock_run = MagicMock(return_value=MagicMock(returncode=0)) with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", - return_value="/custom/extras/dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", + return_value="/custom/litellm/proxy/dir", ), patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), @@ -73,14 +73,14 @@ def test_db_generate_schema_path_uses_get_prisma_dir(cli_runner): assert result.exit_code == 0, result.output call_args = mock_run.call_args[0][0] schema_arg = call_args[call_args.index("--schema") + 1] - assert schema_arg == "/custom/extras/dir/schema.prisma" + assert schema_arg == "/custom/litellm/proxy/dir/schema.prisma" def test_db_generate_prisma_failure(cli_runner): mock_run = MagicMock(side_effect=subprocess.CalledProcessError(1, "prisma")) with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), @@ -97,7 +97,7 @@ def test_db_generate_prisma_failure(cli_runner): def test_db_generate_schema_missing(cli_runner): with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), @@ -166,7 +166,7 @@ def _capture(*args, **kwargs): with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), patch( @@ -213,7 +213,7 @@ def _capture(*args, **kwargs): with ( patch( - "litellm.proxy.client.cli.commands.db.ProxyExtrasDBManager._get_prisma_dir", + "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), patch( From bbfb5ad72e7bc494a328190f9e134e3d3c4de4e3 Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 08:31:42 -0700 Subject: [PATCH 3/7] fix(proxy): satisfy any-discipline type budget in db generate After rebasing onto litellm_internal_staging, db.py is a new file with a zero-Any budget. Type the proxy-extras helper bindings (Optional[Callable]) and _get_generate_env's return as Dict[str, str], guard the optional prisma command at the call site, and mark the unavoidable untyped third-party import boundary with any-ok. No behavior change. --- litellm/proxy/client/cli/commands/db.py | 41 ++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/client/cli/commands/db.py b/litellm/proxy/client/cli/commands/db.py index ccc7715cdee..1884e587db3 100644 --- a/litellm/proxy/client/cli/commands/db.py +++ b/litellm/proxy/client/cli/commands/db.py @@ -4,6 +4,7 @@ import subprocess import sys import sysconfig +from typing import Callable, Dict, List, Optional import click @@ -17,15 +18,27 @@ # handled at call time so `litellm` works without the proxy extras installed. try: from litellm_proxy_extras.utils import ( - _get_prisma_command, - _get_prisma_env, + _get_prisma_command as _imported_prisma_command, + _get_prisma_env as _imported_prisma_env, ) _PROXY_EXTRAS_AVAILABLE = True except ImportError: + _imported_prisma_command = None + _imported_prisma_env = None _PROXY_EXTRAS_AVAILABLE = False - _get_prisma_command = None # type: ignore[assignment] - _get_prisma_env = None # type: ignore[assignment] + +# Bind to explicitly typed module-level names. litellm-proxy-extras ships no +# py.typed marker, so its exports are untyped (Any) at this import boundary; +# the annotations pin the signatures back down. These are the names the tests +# patch. The `any-ok` markers acknowledge the unavoidable untyped third-party +# boundary (the annotation is the concrete type the rest of the file relies on). +_get_prisma_command: Optional[Callable[[], str]] = ( + _imported_prisma_command # any-ok: untyped optional import from litellm-proxy-extras +) +_get_prisma_env: Optional[Callable[[], Dict[str, str]]] = ( + _imported_prisma_env # any-ok: untyped optional import from litellm-proxy-extras +) def _get_venv_scripts_dir() -> str: @@ -47,14 +60,14 @@ def _get_venv_scripts_dir() -> str: return scripts_dir -def _get_generate_env() -> dict: +def _get_generate_env() -> Dict[str, str]: """Build the subprocess env for ``db generate``. Delegates to the shared ``_get_prisma_env()`` which now injects the venv scripts dir for all Prisma subprocesses (generate, db push, migrate deploy). Kept as a named wrapper for backward-compat with existing tests. """ - if _get_prisma_env: + if _get_prisma_env is not None: result = _get_prisma_env() if result is not None: return result @@ -96,10 +109,24 @@ def db_generate() -> None: ) raise SystemExit(1) + if _get_prisma_command is None: + click.echo( + "Error: litellm-proxy-extras is not installed. " + "Run: pip install 'litellm[proxy]'", + err=True, + ) + raise SystemExit(1) + click.echo(f"Generating Prisma client from {schema_path} ...") + command: List[str] = [ + _get_prisma_command(), + "generate", + "--schema", + schema_path, + ] try: subprocess.run( - [_get_prisma_command(), "generate", "--schema", schema_path], + command, check=True, env=_get_generate_env(), ) From 00e35d3019a8bbafb2d59c53f7383b5dc1056821 Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 08:37:40 -0700 Subject: [PATCH 4/7] style: black-format db generate test files Apply black (26.3.1, repo-pinned) to the new db generate test files so they pass the lint job's `black --check .` after the rebase onto litellm_internal_staging. Formatting only, no behavior change. --- .../tests/test_prisma_env_path_injection.py | 23 ++++---- .../test_db_generate_integration.py | 6 ++- .../proxy/client/cli/test_db_commands.py | 52 ++++++++++++++----- .../cli/test_db_generate_path_verifier.py | 44 ++++++++++------ 4 files changed, 84 insertions(+), 41 deletions(-) diff --git a/litellm-proxy-extras/tests/test_prisma_env_path_injection.py b/litellm-proxy-extras/tests/test_prisma_env_path_injection.py index 2be045f6156..1d17d270eda 100644 --- a/litellm-proxy-extras/tests/test_prisma_env_path_injection.py +++ b/litellm-proxy-extras/tests/test_prisma_env_path_injection.py @@ -11,7 +11,6 @@ from litellm_proxy_extras.utils import ProxyExtrasDBManager, _get_prisma_env - # --------------------------------------------------------------------------- # Unit tests for _get_prisma_env() PATH injection # --------------------------------------------------------------------------- @@ -23,15 +22,13 @@ def test_get_prisma_env_injects_scripts_dir_at_index_0(monkeypatch): # Remove scripts dir from PATH so it's not already there stripped_path = "/usr/local/bin:/usr/bin:/bin" monkeypatch.setenv("PATH", stripped_path) - with ( - patch("sysconfig.get_path", return_value=fake_scripts), - ): + with (patch("sysconfig.get_path", return_value=fake_scripts),): env = _get_prisma_env() path_entries = env["PATH"].split(os.pathsep) - assert path_entries[0] == fake_scripts, ( - f"Expected scripts dir at index 0, got: {env['PATH']}" - ) + assert ( + path_entries[0] == fake_scripts + ), f"Expected scripts dir at index 0, got: {env['PATH']}" # Original PATH entries preserved after the injected dir assert "/usr/local/bin" in path_entries assert "/usr/bin" in path_entries @@ -59,7 +56,9 @@ def test_get_prisma_env_handles_empty_path(monkeypatch): env = _get_prisma_env() assert env["PATH"] == fake_scripts - assert os.pathsep not in env["PATH"] or env["PATH"].rstrip(os.pathsep) == fake_scripts + assert ( + os.pathsep not in env["PATH"] or env["PATH"].rstrip(os.pathsep) == fake_scripts + ) def test_get_prisma_env_falls_back_to_dirname_when_sysconfig_falsy(monkeypatch): @@ -129,14 +128,18 @@ def fake_subprocess_run(args, **kwargs): with ( patch("sysconfig.get_path", return_value=fake_scripts), - patch.object(ProxyExtrasDBManager, "_get_prisma_dir", return_value=str(tmp_path)), + patch.object( + ProxyExtrasDBManager, "_get_prisma_dir", return_value=str(tmp_path) + ), patch("litellm_proxy_extras.utils._get_prisma_command", return_value="prisma"), patch("subprocess.run", side_effect=fake_subprocess_run), ): result = ProxyExtrasDBManager.setup_database(use_migrate=False) assert result is True, "setup_database should return True on success" - assert len(captured_calls) == 1, f"Expected 1 subprocess call, got {len(captured_calls)}" + assert ( + len(captured_calls) == 1 + ), f"Expected 1 subprocess call, got {len(captured_calls)}" received_env = captured_calls[0]["env"] assert received_env is not None, "subprocess.run must be called with env=" diff --git a/tests/proxy_cli_integration_tests/test_db_generate_integration.py b/tests/proxy_cli_integration_tests/test_db_generate_integration.py index aa86e582e91..9488011b449 100644 --- a/tests/proxy_cli_integration_tests/test_db_generate_integration.py +++ b/tests/proxy_cli_integration_tests/test_db_generate_integration.py @@ -20,7 +20,9 @@ @pytest.fixture def non_activated_env(): - venv_root = sysconfig.get_path("data") or os.path.dirname(sysconfig.get_path("scripts") or "") + venv_root = sysconfig.get_path("data") or os.path.dirname( + sysconfig.get_path("scripts") or "" + ) env = os.environ.copy() env.pop("VIRTUAL_ENV", None) env.pop("VIRTUAL_ENV_PROMPT", None) @@ -36,7 +38,7 @@ def fake_prisma_bin(tmp_path): fake_bin = tmp_path / "fake_bin" fake_bin.mkdir() prisma = fake_bin / "prisma" - prisma.write_text("#!/bin/sh\necho \"fake-prisma called: $@\"\nexit 0\n") + prisma.write_text('#!/bin/sh\necho "fake-prisma called: $@"\nexit 0\n') prisma.chmod(prisma.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) return fake_bin diff --git a/tests/test_litellm/proxy/client/cli/test_db_commands.py b/tests/test_litellm/proxy/client/cli/test_db_commands.py index 16ba30da2e5..c93952e6095 100644 --- a/tests/test_litellm/proxy/client/cli/test_db_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_db_commands.py @@ -39,8 +39,13 @@ def test_db_generate_success(cli_runner): "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), - patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), - patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None + ), patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), ): @@ -63,8 +68,13 @@ def test_db_generate_schema_path_uses_get_prisma_dir(cli_runner): "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/custom/litellm/proxy/dir", ), - patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), - patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None + ), patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), ): @@ -83,8 +93,13 @@ def test_db_generate_prisma_failure(cli_runner): "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), - patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), - patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None + ), patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), patch("litellm.proxy.client.cli.commands.db.subprocess.run", mock_run), ): @@ -100,9 +115,16 @@ def test_db_generate_schema_missing(cli_runner): "litellm.proxy.client.cli.commands.db.PrismaManager._get_prisma_dir", return_value="/fake/prisma/dir", ), - patch("litellm.proxy.client.cli.commands.db._get_prisma_command", return_value="prisma"), - patch("litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None), - patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=False), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_command", + return_value="prisma", + ), + patch( + "litellm.proxy.client.cli.commands.db._get_prisma_env", return_value=None + ), + patch( + "litellm.proxy.client.cli.commands.db.os.path.exists", return_value=False + ), ): result = cli_runner.invoke(cli, ["db", "generate"]) @@ -119,7 +141,9 @@ def test_db_generate_proxy_extras_not_installed(cli_runner): }, ): # Patch the module-level flag directly since the import already happened - with patch("litellm.proxy.client.cli.commands.db._PROXY_EXTRAS_AVAILABLE", False): + with patch( + "litellm.proxy.client.cli.commands.db._PROXY_EXTRAS_AVAILABLE", False + ): result = cli_runner.invoke(cli, ["db", "generate"]) assert result.exit_code != 0 @@ -179,7 +203,9 @@ def _capture(*args, **kwargs): return_value=injected_env, ), patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), - patch("litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture), + patch( + "litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture + ), ): result = cli_runner.invoke(cli, ["db", "generate"]) @@ -225,7 +251,9 @@ def _capture(*args, **kwargs): return_value={"PATH": scripts_dir + os.pathsep + "/usr/bin"}, ), patch("litellm.proxy.client.cli.commands.db.os.path.exists", return_value=True), - patch("litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture), + patch( + "litellm.proxy.client.cli.commands.db.subprocess.run", side_effect=_capture + ), ): result = cli_runner.invoke(cli, ["db", "generate"]) diff --git a/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py b/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py index 42173788aaf..022fc6bab68 100644 --- a/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py +++ b/tests/test_litellm/proxy/client/cli/test_db_generate_path_verifier.py @@ -7,7 +7,10 @@ import pytest -from litellm.proxy.client.cli.commands.db import _get_generate_env, _get_venv_scripts_dir +from litellm.proxy.client.cli.commands.db import ( + _get_generate_env, + _get_venv_scripts_dir, +) _DB_MODULE = "litellm.proxy.client.cli.commands.db" @@ -100,15 +103,20 @@ def test_prisma_client_py_resolvable_via_path(): fake_exe = os.path.join(tmpdir, "prisma-client-py") with open(fake_exe, "w") as f: f.write("#!/bin/sh\necho fake\n") - os.chmod(fake_exe, os.stat(fake_exe).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + os.chmod( + fake_exe, + os.stat(fake_exe).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH, + ) - with patch(f"{_DB_MODULE}._get_prisma_env", return_value={"PATH": f"{tmpdir}:/usr/bin"}): + with patch( + f"{_DB_MODULE}._get_prisma_env", return_value={"PATH": f"{tmpdir}:/usr/bin"} + ): env = _get_generate_env() path_entries = env["PATH"].split(os.pathsep) - assert path_entries[0] == tmpdir, ( - f"scripts dir must be first on PATH; got {path_entries!r}" - ) + assert ( + path_entries[0] == tmpdir + ), f"scripts dir must be first on PATH; got {path_entries!r}" found = shutil.which("prisma-client-py", path=env["PATH"]) assert found is not None, "prisma-client-py not findable via PATH lookup" assert os.path.dirname(found) == tmpdir @@ -125,12 +133,14 @@ def test_prisma_client_py_resolvable_via_path(): def test_scripts_dir_promoted_to_index_zero_when_at_later_position(): fake_scripts = "/home/user/venv/bin" - promoted_path = os.pathsep.join([ - fake_scripts, - "/usr/bin", - "/opt/other", - "/bin", - ]) + promoted_path = os.pathsep.join( + [ + fake_scripts, + "/usr/bin", + "/opt/other", + "/bin", + ] + ) promoted_env = {"PATH": promoted_path} with patch(f"{_DB_MODULE}._get_prisma_env", return_value=promoted_env): @@ -143,9 +153,7 @@ def test_scripts_dir_promoted_to_index_zero_when_at_later_position(): f"_get_prisma_env() is responsible for the promotion; " f"_get_generate_env() must faithfully delegate." ) - assert path_entries.count(fake_scripts) == 1, ( - "scripts_dir must not be duplicated" - ) + assert path_entries.count(fake_scripts) == 1, "scripts_dir must not be duplicated" # --------------------------------------------------------------------------- @@ -167,7 +175,10 @@ def test_get_generate_env_delegation_chain_returns_scripts_dir_at_index_0(): fake_scripts = "/fake/venv/bin" base_env = {"PATH": "/usr/bin:/bin"} with ( - patch(f"{_DB_MODULE}._get_prisma_env", return_value={**base_env, "PATH": f"{fake_scripts}:/usr/bin:/bin"}), + patch( + f"{_DB_MODULE}._get_prisma_env", + return_value={**base_env, "PATH": f"{fake_scripts}:/usr/bin:/bin"}, + ), ): env = _get_generate_env() @@ -190,4 +201,3 @@ def test_get_generate_env_falls_back_to_os_environ_when_prisma_env_is_none(): # Must return a dict (a copy of os.environ) without raising assert isinstance(env, dict) assert len(env) > 0 - From 74bace628aa88362d01d141e463c741d8263222a Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 08:44:25 -0700 Subject: [PATCH 5/7] style: use PEP 585 builtin generics in db generate types The ruff strict-rule budget (UP006) flags typing.Dict/List; switch to builtin dict[str, str] and list[str] to stay under the codebase ceiling. No behavior change. --- litellm/proxy/client/cli/commands/db.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/client/cli/commands/db.py b/litellm/proxy/client/cli/commands/db.py index 1884e587db3..27233806e99 100644 --- a/litellm/proxy/client/cli/commands/db.py +++ b/litellm/proxy/client/cli/commands/db.py @@ -4,7 +4,7 @@ import subprocess import sys import sysconfig -from typing import Callable, Dict, List, Optional +from typing import Callable, Optional import click @@ -36,7 +36,7 @@ _get_prisma_command: Optional[Callable[[], str]] = ( _imported_prisma_command # any-ok: untyped optional import from litellm-proxy-extras ) -_get_prisma_env: Optional[Callable[[], Dict[str, str]]] = ( +_get_prisma_env: Optional[Callable[[], dict[str, str]]] = ( _imported_prisma_env # any-ok: untyped optional import from litellm-proxy-extras ) @@ -60,7 +60,7 @@ def _get_venv_scripts_dir() -> str: return scripts_dir -def _get_generate_env() -> Dict[str, str]: +def _get_generate_env() -> dict[str, str]: """Build the subprocess env for ``db generate``. Delegates to the shared ``_get_prisma_env()`` which now injects the venv @@ -118,7 +118,7 @@ def db_generate() -> None: raise SystemExit(1) click.echo(f"Generating Prisma client from {schema_path} ...") - command: List[str] = [ + command: list[str] = [ _get_prisma_command(), "generate", "--schema", From 0ea1ece8643e6c1782fbc09554a064a1201852db Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 09:19:44 -0700 Subject: [PATCH 6/7] ci: retrigger checks (Vertex AI + documentation cancelled by concurrency) From 364af60b1e97df33896c92593ef5106c2dcec590 Mon Sep 17 00:00:00 2001 From: Stephen Chin Date: Wed, 17 Jun 2026 10:22:39 -0700 Subject: [PATCH 7/7] ci: retrigger checks (prisma client npm fetch flake, 20m timeout)