Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _load_from_file(path: str) -> str:

def _parse_csv(self, content: str, source_ref: str) -> LoaderResult:
try:
csv_reader = csv.DictReader(StringIO(content))
csv_reader = csv.DictReader(StringIO(content.removeprefix("\ufeff")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode URL responses before stripping the BOM

When a URL serves BOM-prefixed UTF-8 bytes as Content-Type: text/csv without a charset, Requests assigns ISO-8859-1 before load_from_url() reads response.text, so this method receives name,... rather than a leading U+FEFF. The new removeprefix() is therefore a no-op and the first header remains corrupted; the URL test masks this by mocking .text as already-correct Unicode. Detect the raw BOM and select utf-8-sig before reading the response text, and exercise that behavior with a byte-backed response.

AGENTS.md reference: AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced with a real requests.Response containing UTF-8 BOM bytes and Content-Type: text/csv without a charset. Requests selected Latin-1, and four byte-backed CSV regression cases failed on the previous implementation.

Fixed in 0e5075d. The shared load_from_url() helper now detects BOM_UTF8 in response.content and selects utf-8-sig before reading response.text.

The URL tests now exercise real response decoding and cover quoted/unquoted headers, non-ASCII values, BOM-free UTF-8 and Latin-1 responses, and embedded U+FEFF. Additional JSON/XML tests preserve compatibility with automatic and explicit BOM decoding in other users of the shared helper.

Validation: 68 tests passed, including 30 CSV tests; Ruff lint/format and mypy passed. The only warning is the existing crewai.utilities.lock_store deprecation warning.


text_parts = []
headers = csv_reader.fieldnames
Expand Down
4 changes: 4 additions & 0 deletions lib/crewai-tools/src/crewai_tools/rag/loaders/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Utility functions for RAG loaders."""

from codecs import BOM_UTF8
from typing import Any


Expand Down Expand Up @@ -36,6 +37,9 @@ def load_from_url(
try:
response = safe_get(url, headers=headers, timeout=30)
response.raise_for_status()
if response.content.startswith(BOM_UTF8):
# Honor the UTF-8 signature over Requests' text/* Latin-1 fallback.
response.encoding = "utf-8-sig"
return response.text
except Exception as e:
raise ValueError(f"Error fetching content from URL {url}: {e!s}") from e
82 changes: 82 additions & 0 deletions lib/crewai-tools/tests/rag/test_csv_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from crewai_tools.rag.loaders.csv_loader import CSVLoader
from crewai_tools.rag.source_content import SourceContent
import pytest
from requests import Response
from requests.utils import get_encoding_from_headers


@pytest.fixture
Expand Down Expand Up @@ -76,6 +78,86 @@ def test_load_csv_text_input(self):
assert result.metadata["columns"] == ["col1", "col2"]
assert result.metadata["rows"] == 2

@pytest.mark.parametrize("source_kind", ["text", "file", "url"])
@pytest.mark.parametrize("bom", ["", "\ufeff"], ids=["utf-8", "utf-8-bom"])
@pytest.mark.parametrize(
"header, column", [("name", "name"), ('"last, first"', "last, first")]
)
def test_load_csv_with_utf8_bom(self, tmp_path, source_kind, bom, header, column):
"""Parse quoted and plain headers across UTF-8 input sources."""
raw_csv = f'{bom}{header},age\n"Doe, Jane",30\n'
if source_kind == "file":
path = tmp_path / "people.csv"
path.write_bytes(raw_csv.encode("utf-8"))
source = SourceContent(path)
elif source_kind == "url":
source = SourceContent("https://example.com/people.csv")
else:
source = SourceContent(raw_csv)

with patch("crewai_tools.security.safe_requests._raw_get") as mock_get:
response = Response()
response.status_code = 200
response.headers["Content-Type"] = "text/csv"
response.encoding = get_encoding_from_headers(response.headers)
response._content = raw_csv.encode("utf-8")
mock_get.return_value = response
result = CSVLoader().load(source)

assert result.metadata == {
"format": "csv",
"columns": [column, "age"],
"rows": 1,
}
assert f"Row 1: {column}: Doe, Jane | age: 30" in result.content
assert result.source == source.source_ref

@pytest.mark.parametrize("source_kind", ["text", "url"])
def test_load_csv_preserves_bom_inside_field(self, source_kind):
"""Remove the file signature while preserving BOMs in field values."""
raw_csv = "\ufeffname,value\nAlice,\ufeffkeep\ufeff\n"
source = SourceContent(
"https://example.com/data.csv" if source_kind == "url" else raw_csv
)
response = Response()
response.status_code = 200
response.headers["Content-Type"] = "text/csv"
response.encoding = get_encoding_from_headers(response.headers)
response._content = raw_csv.encode("utf-8")
with patch(
"crewai_tools.security.safe_requests._raw_get", return_value=response
):
result = CSVLoader().load(source)

assert result.metadata["columns"] == ["name", "value"]
assert "value: \ufeffkeep\ufeff" in result.content

@pytest.mark.parametrize(
"content_type, encoding",
[
("text/csv", "utf-8-sig"),
("text/csv; charset=utf-8", "utf-8-sig"),
("application/csv", "utf-8-sig"),
("text/csv; charset=utf-8", "utf-8"),
("text/csv", "latin-1"),
("text/csv; charset=iso-8859-1", "latin-1"),
],
)
def test_load_csv_from_url_preserves_encoding(self, content_type, encoding):
"""Decode UTF-8 signatures without changing BOM-free response encodings."""
response = Response()
response.status_code = 200
response.headers["Content-Type"] = content_type
response.encoding = get_encoding_from_headers(response.headers)
response._content = "name,city\nAndré,Montréal\n".encode(encoding)
with patch(
"crewai_tools.security.safe_requests._raw_get", return_value=response
):
result = CSVLoader().load(SourceContent("https://example.com/data.csv"))

assert result.metadata["columns"] == ["name", "city"]
assert "Row 1: name: André | city: Montréal" in result.content

def test_doc_id_is_deterministic(self, temp_csv_file):
path = temp_csv_file("name,value\ntest,123")
loader = CSVLoader()
Expand Down
46 changes: 46 additions & 0 deletions lib/crewai-tools/tests/rag/test_loader_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from unittest.mock import patch

from crewai_tools.rag.loaders.json_loader import JSONLoader
from crewai_tools.rag.loaders.xml_loader import XMLLoader
from crewai_tools.rag.source_content import SourceContent
import pytest
from requests import Response
from requests.utils import get_encoding_from_headers


@pytest.mark.parametrize("charset", ["", "; charset=utf-8-sig"])
@pytest.mark.parametrize(
"loader, content_type, content, metadata",
[
(
JSONLoader,
"application/octet-stream",
'{"message": "hello"}',
{"format": "json", "type": "dict", "size": 1},
),
(
XMLLoader,
"application/xml",
"<root>hello</root>",
{"format": "xml", "root_tag": "root"},
),
],
)
def test_url_loaders_preserve_bom_decoding(
loader: type[JSONLoader] | type[XMLLoader],
content_type: str,
content: str,
metadata: dict[str, str | int],
charset: str,
) -> None:
"""Keep JSON and XML parsing compatible with automatic or explicit BOM decoding."""
response = Response()
response.status_code = 200
response.headers["Content-Type"] = content_type + charset
response.encoding = get_encoding_from_headers(response.headers)
response._content = content.encode("utf-8-sig")
with patch("crewai_tools.security.safe_requests._raw_get", return_value=response):
result = loader().load(SourceContent("https://example.com/data"))

assert result.metadata == metadata
assert "hello" in result.content