diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a5dcfd..ad4a129a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido - +- A mensagem de pacote ausente de um provider do LangChain só sugere `pip install dataframeit[all]` quando o pacote do provider está no extra `all`, lido dos metadados do pacote instalado. Para Mistral, Cohere, Bedrock, Vertex AI e outros providers sem extra próprio, ela sugere só o `langchain-`, porque o `all` não os instala (#279). ## [0.11.1] - 2026-10-29 diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index 5e364a85..98b49cb6 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -4,7 +4,7 @@ ### `ImportError` asking you to install a package -The chosen provider needs its package, and the message says what to install. For LangChain providers, it suggests the provider's package or the `all` extra: +The chosen provider needs its package, and the message says what to install. For LangChain providers, it suggests the provider's package and, when the package is in the `all` extra, `all` as well: ```bash pip install langchain-anthropic @@ -12,7 +12,7 @@ pip install langchain-anthropic pip install dataframeit[all] ``` -The provider's extra, such as `dataframeit[anthropic]`, installs the same package. `all` brings the providers that have an extra, except Codex; LangChain providers without their own extra, such as Cohere and Mistral, need the `langchain-` package, which `all` does not include. For `claude_code` and `codex`, the message asks for the provider's extra (`dataframeit[claude-code]`, `dataframeit[codex]`), and for web search, the search provider's package. The extras are listed in [Installation](../getting-started/installation.md). +The provider's extra, such as `dataframeit[anthropic]`, installs the same package. `all` brings the providers that have an extra, except Codex; LangChain providers without their own extra, such as Cohere and Mistral, need the `langchain-` package, which `all` does not include, and the message suggests only that package. For `claude_code` and `codex`, the message asks for the provider's extra (`dataframeit[claude-code]`, `dataframeit[codex]`), and for web search, the search provider's package. The extras are listed in [Installation](../getting-started/installation.md). ### Authentication error (401 or 403) diff --git a/docs/guides/faq.md b/docs/guides/faq.md index eee02ca9..35093c48 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -4,7 +4,7 @@ ### `ImportError` pedindo para instalar um pacote -O provider escolhido precisa do pacote dele, e a mensagem diz o que instalar. Nos providers do LangChain, ela sugere o pacote do provider ou o extra `all`: +O provider escolhido precisa do pacote dele, e a mensagem diz o que instalar. Nos providers do LangChain, ela sugere o pacote do provider e, quando o pacote está no extra `all`, também o `all`: ```bash pip install langchain-anthropic @@ -12,7 +12,7 @@ pip install langchain-anthropic pip install dataframeit[all] ``` -O extra do provider, como `dataframeit[anthropic]`, instala o mesmo pacote. O `all` traz os providers que têm extra, menos o Codex; os providers do LangChain sem extra próprio, como Cohere e Mistral, precisam do pacote `langchain-`, que o `all` não inclui. No `claude_code` e no `codex`, a mensagem pede o extra do provider (`dataframeit[claude-code]`, `dataframeit[codex]`), e na busca web, o pacote do provedor de busca. Os extras estão em [Instalação](../getting-started/installation.md). +O extra do provider, como `dataframeit[anthropic]`, instala o mesmo pacote. O `all` traz os providers que têm extra, menos o Codex; os providers do LangChain sem extra próprio, como Cohere e Mistral, precisam do pacote `langchain-`, que o `all` não inclui, e a mensagem sugere só ele. No `claude_code` e no `codex`, a mensagem pede o extra do provider (`dataframeit[claude-code]`, `dataframeit[codex]`), e na busca web, o pacote do provedor de busca. Os extras estão em [Instalação](../getting-started/installation.md). ### Erro de autenticação (401 ou 403) diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index bf0f5a47..9500ecd9 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -10,6 +10,7 @@ from __future__ import annotations import importlib +import importlib.metadata import os import random import re @@ -275,6 +276,39 @@ def _infer_provider_info(provider: str | None) -> dict: } +_EXTRA_ALL_MARKER = re.compile(r"""extra\s*==\s*["']all["']""") +_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") + + +def _normalize_distribution_name(name: str) -> str: + # Normalização da PEP 503: "langchain_openai" e "LangChain-OpenAI" são o + # mesmo pacote, e é assim que o nome de instalação se compara ao requisito. + return re.sub(r"[-_.]+", "-", name).lower() + + +def _all_extra_packages() -> frozenset[str]: + """Nomes normalizados dos pacotes que o extra ``all`` instala. + + Lidos dos metadados do dataframeit instalado, que o build gera do + ``pyproject.toml``, para que a dica de ``dataframeit[all]`` não divirja do + extra. Sem os metadados, como ao rodar o código fonte sem instalar o + pacote, devolve vazio: não há como saber o que o extra traz, e sugeri-lo + poderia mandar instalar algo que não resolve. + """ + try: + requirements = importlib.metadata.requires("dataframeit") or [] + except importlib.metadata.PackageNotFoundError: + return frozenset() + packages = set() + for requirement in requirements: + # O marcador de ambiente vem depois do ";", e é nele que o extra aparece. + name_part, _, marker = requirement.partition(";") + match = _REQUIREMENT_NAME.match(name_part.strip()) + if match and _EXTRA_ALL_MARKER.search(marker): + packages.add(_normalize_distribution_name(match.group())) + return frozenset(packages) + + def _get_missing_package_message( package: str, install_name: str, @@ -360,8 +394,15 @@ def validate_provider_dependencies(provider: str | None) -> None: try: importlib.import_module(package) except ImportError as err: + # O extra all só traz os providers que têm extra próprio; para os + # demais, como mistralai e cohere, sugeri-lo não resolveria. + alternative = ( + "dataframeit[all]" + if _normalize_distribution_name(install) in _all_extra_packages() + else None + ) raise ImportError( - _get_missing_package_message(package, install, name, "dataframeit[all]") + _get_missing_package_message(package, install, name, alternative) ) from err diff --git a/tests/test_erros_de_provider.py b/tests/test_erros_de_provider.py index 0a574775..a602f01c 100644 --- a/tests/test_erros_de_provider.py +++ b/tests/test_erros_de_provider.py @@ -1,10 +1,15 @@ """Classificação e mensagens de erro dos providers.""" import importlib +import importlib.metadata import importlib.util +import tomllib +from pathlib import Path from types import SimpleNamespace import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name from dataframeit import errors from dataframeit.claude_code import _raise_for_result_error @@ -350,6 +355,64 @@ def importar(nome): assert "dataframeit[all]" in str(exc_info.value) +def _sem_o_pacote(monkeypatch, pacote): + import_module = importlib.import_module + + def importar(nome): + if nome == pacote: + raise ImportError(nome) + return import_module(nome) + + monkeypatch.setattr(importlib, "import_module", importar) + + +@pytest.mark.parametrize( + ("provider", "pacote", "instalar", "sugere_all"), + [ + ("openai", "langchain_openai", "langchain-openai", True), + ("azure_openai", "langchain_openai", "langchain-openai", True), + ("mistralai", "langchain_mistralai", "langchain-mistralai", False), + ("cohere", "langchain_cohere", "langchain-cohere", False), + ("bedrock", "langchain_aws", "langchain-aws", False), + ], +) +def test_dica_do_extra_all_so_para_provider_cujo_pacote_esta_nele( + monkeypatch, provider, pacote, instalar, sugere_all +): + _sem_o_pacote(monkeypatch, pacote) + + with pytest.raises(ImportError, match=f"pip install {instalar} ") as exc_info: + validate_provider_dependencies(provider) + + assert ("dataframeit[all]" in str(exc_info.value)) is sugere_all + + +def test_sem_metadados_do_pacote_a_dica_do_extra_all_some(monkeypatch): + """Sem os metadados, não há como saber o que o extra traz, e a dica não sai.""" + + def sem_metadados(nome): + raise importlib.metadata.PackageNotFoundError(nome) + + monkeypatch.setattr(importlib.metadata, "requires", sem_metadados) + _sem_o_pacote(monkeypatch, "langchain_openai") + + with pytest.raises(ImportError, match="pip install langchain-openai ") as exc_info: + validate_provider_dependencies("openai") + + assert "dataframeit[all]" not in str(exc_info.value) + + +def test_pacotes_do_extra_all_lidos_dos_metadados_batem_com_o_pyproject(): + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + declarados = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"][ + "optional-dependencies" + ]["all"] + + assert errors._all_extra_packages() == { + canonicalize_name(Requirement(r).name) for r in declarados + } + + def test_sem_provider_valida_so_o_langchain(monkeypatch): """Com provider=None, o init_chat_model infere o provider pelo nome do modelo.""" importados = []