diff --git a/graphify/llm.py b/graphify/llm.py index 20f3ff1..21c6372 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -395,6 +395,21 @@ def _default_model_for_backend(backend: str) -> str: return cfg["default_model"] +def _backend_pkg_hint(pkg: str, extra: str) -> str: + """Package-missing message that works for the recommended `uv tool` install. + + `uv tool install graphifyy` puts graphify in an isolated venv, so a plain + `pip install ` never reaches it - the friction a user hits when a + backend needs anthropic/openai/boto3 and the only advice was "pip install". + Point at the extra and the uv path first, then the pip/venv fallback. + """ + return ( + f"the '{pkg}' package is required for this backend but is not installed. " + f"Install it with: uv tool install \"graphifyy[{extra}]\" --force " + f"(uv tool), or pip install {pkg} (pip/venv install)." + ) + + def _call_openai_compat( base_url: str, api_key: str, @@ -411,11 +426,8 @@ def _call_openai_compat( try: from openai import OpenAI except ImportError as exc: - pkg_hint = "graphifyy[kimi]" if backend == "kimi" else "openai" - raise ImportError( - "Gemini/Kimi/Ollama/OpenAI-compatible extraction requires the openai package. " - f"Run: pip install {pkg_hint}" - ) from exc + extra = backend if backend in ("kimi", "gemini", "openai", "ollama") else "openai" + raise ImportError(_backend_pkg_hint("openai", extra)) from exc # Local backends (ollama, llama.cpp, vLLM) routinely take >60s for a # single chunk on a large model — far longer than the openai SDK's @@ -536,10 +548,7 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = try: import anthropic except ImportError as exc: - raise ImportError( - "Claude direct extraction requires the anthropic package. " - "Run: pip install anthropic" - ) from exc + raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc client = anthropic.Anthropic(api_key=api_key) resp = client.messages.create( @@ -1172,7 +1181,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: try: import anthropic except ImportError as exc: - raise ImportError("anthropic package required for claude backend") from exc + raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc client = anthropic.Anthropic(api_key=key) resp = client.messages.create( model=mdl, @@ -1206,7 +1215,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: try: import boto3 except ImportError as exc: - raise ImportError("boto3 required for bedrock backend") from exc + raise ImportError(_backend_pkg_hint("boto3", "bedrock")) from exc region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" profile = os.environ.get("AWS_PROFILE") session = boto3.Session(profile_name=profile, region_name=region) @@ -1222,7 +1231,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: try: from openai import OpenAI except ImportError as exc: - raise ImportError("openai package required for this backend") from exc + raise ImportError(_backend_pkg_hint("openai", "openai")) from exc client = OpenAI(api_key=key, base_url=cfg["base_url"]) kwargs: dict = { "model": mdl, diff --git a/pyproject.toml b/pyproject.toml index 93f5c8e..ed419c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ video = ["faster-whisper; python_version >= '3.11'", "yt-dlp"] kimi = ["openai", "tiktoken"] ollama = ["openai"] bedrock = ["boto3"] +anthropic = ["anthropic"] gemini = ["openai", "tiktoken"] openai = ["openai", "tiktoken"] chinese = ["jieba"] @@ -68,7 +69,7 @@ sql = ["tree-sitter-sql"] # must compile from source (needs a C toolchain + python3-dev). Keeping it optional # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] -all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "openai", "tiktoken", "boto3", "tree-sitter-sql", "jieba", "tree-sitter-dm"] +all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/test_backend_extras.py b/tests/test_backend_extras.py new file mode 100644 index 0000000..f513c57 --- /dev/null +++ b/tests/test_backend_extras.py @@ -0,0 +1,41 @@ +"""The claude backend must be installable via an extra, and the missing-package +message must point uv-tool users at the right command. + +Friction this guards: `uv tool install graphifyy` puts graphify in an isolated +venv. A user with ANTHROPIC_API_KEY set then hit "anthropic package required" +with no extra to satisfy it (claude was the only backend with no `[extra]`), and +the message said `pip install anthropic`, which does not reach a uv tool venv. +""" +from pathlib import Path + +from graphify.llm import _backend_pkg_hint + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + + +def _extras(): + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + return data["project"]["optional-dependencies"] + + +def test_anthropic_extra_exists(): + extras = _extras() + assert "anthropic" in extras, "claude backend needs a [anthropic] extra" + assert any("anthropic" in dep for dep in extras["anthropic"]) + + +def test_anthropic_in_all_extra(): + extras = _extras() + assert any("anthropic" in dep for dep in extras["all"]), "[all] must include anthropic" + + +def test_backend_pkg_hint_points_at_uv_tool_and_extra(): + msg = _backend_pkg_hint("anthropic", "anthropic") + assert "uv tool install" in msg + assert 'graphifyy[anthropic]' in msg + assert "pip install anthropic" in msg # pip/venv fallback still mentioned diff --git a/uv.lock b/uv.lock index 2ac33d0..b84ecb4 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.105.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/46/47581b8c689c743ceabf6a0f9ff48472160900ce802d26c0fb50423997b3/anthropic-0.105.2.tar.gz", hash = "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", size = 853789, upload-time = "2026-05-29T00:21:14.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/75/be0c357e33a5a56c8f9db5b4212f886138d2bf59c0952d858f6b75d710ef/anthropic-0.105.2-py3-none-any.whl", hash = "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230", size = 837507, upload-time = "2026-05-29T00:21:15.528Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -931,6 +950,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "dparse" version = "0.6.4" @@ -1146,6 +1174,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "anthropic" }, { name = "boto3" }, { name = "faster-whisper", marker = "python_full_version >= '3.11'" }, { name = "graspologic", marker = "python_full_version < '3.13'" }, @@ -1164,6 +1193,9 @@ all = [ { name = "watchdog" }, { name = "yt-dlp" }, ] +anthropic = [ + { name = "anthropic" }, +] bedrock = [ { name = "boto3" }, ] @@ -1243,6 +1275,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'all'" }, + { name = "anthropic", marker = "extra == 'anthropic'" }, { name = "boto3", marker = "extra == 'all'" }, { name = "boto3", marker = "extra == 'bedrock'" }, { name = "datasketch" }, @@ -1313,7 +1347,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'" }, { name = "yt-dlp", marker = "extra == 'video'" }, ] -provides-extras = ["mcp", "neo4j", "pdf", "watch", "svg", "leiden", "office", "google", "video", "kimi", "ollama", "bedrock", "gemini", "openai", "chinese", "sql", "dm", "all"] +provides-extras = ["mcp", "neo4j", "pdf", "watch", "svg", "leiden", "office", "google", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "dm", "all"] [package.metadata.requires-dev] dev = [