Skip to content

Commit 3ad95ca

Browse files
joaomdmouraclaude
andcommitted
fix(tools): address review feedback on URLReadTool
Bound start_line and line_count with ge=1 in the args schema. Both were unbounded, and _window computes stop as start + line_count, so a negative line_count reached islice, which rejects a negative stop. The windowing runs outside the tool's error handling, so that escaped _run as a raw ValueError instead of an error string. BaseTool.run validates kwargs against args_schema, so the constraint refuses the value before any request is made. _window also clamps its own bounds now, so it cannot raise if called directly. Bound the PDFLoader download with safe_get_bounded. The body is held in memory for the whole extraction, so it needed a ceiling; it defaults to 50 MiB and takes a max_bytes kwarg to load() for callers ingesting larger documents. Patch the loader's own seam in its tests rather than requests.get. Both safe_get and safe_get_bounded resolve the hostname before requesting, so the previous mocks made the tests depend on DNS for example.com and fail in a network-isolated runner for reasons unrelated to the loader. Exercise the content-type fallback through run() rather than asserting on _resolve_kind, so the tests survive a refactor of the classification internals, and cover the octet-stream PDF, missing-type, query-string and unknown-extension cases as observable behavior. Add docstrings to the new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 26ff36a commit 3ad95ca

5 files changed

Lines changed: 174 additions & 53 deletions

File tree

lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77

88
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
99
from crewai_tools.rag.source_content import SourceContent
10-
from crewai_tools.security.safe_requests import safe_get
10+
from crewai_tools.security.safe_requests import safe_get_bounded
11+
12+
13+
# Remote PDFs are held in memory for the whole extraction, so the download needs
14+
# a ceiling. Override per call with the ``max_bytes`` kwarg to ``load``.
15+
DEFAULT_MAX_PDF_BYTES = 50 * 1024 * 1024
1116

1217

1318
class PDFLoader(BaseLoader):
@@ -28,17 +33,19 @@ def _fetch_from_url(url: str, kwargs: dict[str, Any]) -> bytes:
2833
2934
The content stays in memory rather than going to a temporary file: the
3035
whole body has to be buffered either way, and a temp file would need
31-
unlinking on every error path to avoid leaving files behind.
36+
unlinking on every error path to avoid leaving files behind. Because it
37+
is held in memory, the download is capped.
3238
3339
Args:
3440
url: The URL to download from.
35-
kwargs: Optional dict that may contain custom headers.
41+
kwargs: Optional dict that may contain custom ``headers`` and a
42+
``max_bytes`` ceiling for the download.
3643
3744
Returns:
3845
The raw PDF content.
3946
4047
Raises:
41-
ValueError: If the download fails.
48+
ValueError: If the download fails or exceeds the size ceiling.
4249
"""
4350
headers = kwargs.get(
4451
"headers",
@@ -49,9 +56,13 @@ def _fetch_from_url(url: str, kwargs: dict[str, Any]) -> bytes:
4956
)
5057

5158
try:
52-
response = safe_get(url, headers=headers, timeout=30)
53-
response.raise_for_status()
54-
return response.content
59+
body, _content_type, _final_url = safe_get_bounded(
60+
url,
61+
max_bytes=kwargs.get("max_bytes", DEFAULT_MAX_PDF_BYTES),
62+
headers=headers,
63+
timeout=30,
64+
)
65+
return body
5566
except Exception as e:
5667
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
5768

lib/crewai-tools/src/crewai_tools/tools/url_read_tool/url_read_tool.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,11 @@ class URLReadToolSchema(BaseModel):
8181
),
8282
)
8383
start_line: int | None = Field(
84-
1, description="Line number to start reading from (1-indexed)"
84+
1, ge=1, description="Line number to start reading from (1-indexed)"
8585
)
8686
line_count: int | None = Field(
8787
None,
88+
ge=1,
8889
description="Number of lines to read. If None, reads the entire content",
8990
)
9091

@@ -311,12 +312,16 @@ def _window(text: str, start_line: int, line_count: int | None) -> str:
311312
312313
The whole body has already been fetched by this point, so unlike the
313314
filesystem equivalent this only trims output -- it saves no transfer.
315+
316+
The bounds are clamped rather than trusted: the args schema rejects
317+
anything below 1 before it gets here, but islice raises on a negative
318+
stop index, and this runs outside the caller's error handling.
314319
"""
315320
if start_line == 1 and line_count is None:
316321
return text
317322

318323
start_index = max(start_line - 1, 0)
319-
stop_index = None if line_count is None else start_index + line_count
324+
stop_index = None if line_count is None else start_index + max(line_count, 0)
320325
selected = list(islice(text.splitlines(keepends=True), start_index, stop_index))
321326

322327
if not selected and start_index > 0:

lib/crewai-tools/tests/rag/test_pdf_loader.py

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import tempfile
2-
from unittest.mock import Mock, patch
2+
from unittest.mock import patch
33

44
from crewai_tools.rag.base_loader import LoaderResult
55
from crewai_tools.rag.loaders.pdf_loader import PDFLoader
@@ -9,6 +9,11 @@
99

1010
pymupdf = pytest.importorskip("pymupdf")
1111

12+
# Patched at the loader's seam rather than at requests.get: safe_get_bounded
13+
# resolves the hostname before issuing a request, which would make these tests
14+
# depend on DNS for example.com.
15+
FETCH = "crewai_tools.rag.loaders.pdf_loader.safe_get_bounded"
16+
1217

1318
def build_pdf(text: str = "Quarterly revenue was 42") -> bytes:
1419
"""Return the bytes of a one-page PDF containing *text*."""
@@ -20,8 +25,14 @@ def build_pdf(text: str = "Quarterly revenue was 42") -> bytes:
2025
document.close()
2126

2227

28+
def fetch_result(body: bytes, url: str = "https://example.com/report.pdf"):
29+
"""Build the (body, content_type, final_url) tuple safe_get_bounded returns."""
30+
return body, "application/pdf", url
31+
32+
2333
class TestPDFLoader:
2434
def test_load_pdf_from_file(self):
35+
"""A PDF on disk has its text extracted with page markers."""
2536
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
2637
f.write(build_pdf())
2738
f.flush()
@@ -35,20 +46,16 @@ def test_load_pdf_from_file(self):
3546
assert result.metadata["file_type"] == "pdf"
3647

3748
def test_load_pdf_from_url(self):
38-
with patch("requests.get") as mock_get:
39-
mock_get.return_value = Mock(
40-
content=build_pdf("Content from URL"),
41-
raise_for_status=Mock(),
42-
status_code=200,
43-
headers={},
44-
)
49+
"""A PDF fetched from a URL is extracted and attributed to that URL."""
50+
with patch(FETCH) as fetch:
51+
fetch.return_value = fetch_result(build_pdf("Content from URL"))
4552
result = PDFLoader().load(SourceContent("https://example.com/report.pdf"))
4653

4754
assert "Content from URL" in result.content
4855
assert result.source == "https://example.com/report.pdf"
4956
assert result.metadata["file_name"] == "report.pdf"
5057

51-
headers = mock_get.call_args[1]["headers"]
58+
headers = fetch.call_args.kwargs["headers"]
5259
assert headers["Accept"] == "application/pdf"
5360
assert "crewai-tools PDFLoader" in headers["User-Agent"]
5461

@@ -59,45 +66,63 @@ def test_load_pdf_from_url_leaves_no_temp_file(self):
5966
so every PDF ingested from a URL left a file behind.
6067
"""
6168
with (
62-
patch("requests.get") as mock_get,
69+
patch(FETCH) as fetch,
6370
patch("tempfile.NamedTemporaryFile") as mock_tempfile,
6471
):
65-
mock_get.return_value = Mock(
66-
content=build_pdf(),
67-
raise_for_status=Mock(),
68-
status_code=200,
69-
headers={},
70-
)
72+
fetch.return_value = fetch_result(build_pdf())
7173
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
7274

7375
mock_tempfile.assert_not_called()
7476

77+
def test_load_pdf_from_url_is_size_bounded(self):
78+
"""The download is capped, since the body is held in memory."""
79+
with patch(FETCH) as fetch:
80+
fetch.return_value = fetch_result(build_pdf())
81+
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
82+
83+
assert fetch.call_args.kwargs["max_bytes"] == 50 * 1024 * 1024
84+
85+
def test_load_pdf_from_url_accepts_a_custom_size_limit(self):
86+
"""Callers can lower or raise the ceiling per load."""
87+
with patch(FETCH) as fetch:
88+
fetch.return_value = fetch_result(build_pdf())
89+
PDFLoader().load(
90+
SourceContent("https://example.com/report.pdf"), max_bytes=1024
91+
)
92+
93+
assert fetch.call_args.kwargs["max_bytes"] == 1024
94+
7595
def test_load_pdf_from_url_with_custom_headers(self):
96+
"""Caller-supplied headers replace the loader's defaults."""
7697
custom_headers = {"Authorization": "Bearer token"}
7798

78-
with patch("requests.get") as mock_get:
79-
mock_get.return_value = Mock(
80-
content=build_pdf(),
81-
raise_for_status=Mock(),
82-
status_code=200,
83-
headers={},
84-
)
99+
with patch(FETCH) as fetch:
100+
fetch.return_value = fetch_result(build_pdf())
85101
PDFLoader().load(
86102
SourceContent("https://example.com/report.pdf"), headers=custom_headers
87103
)
88104

89-
assert mock_get.call_args[1]["headers"] == custom_headers
105+
assert fetch.call_args.kwargs["headers"] == custom_headers
90106

91107
def test_load_pdf_url_download_error(self):
92-
with patch("requests.get", side_effect=Exception("Network error")):
108+
"""A failed download surfaces as a ValueError naming the URL."""
109+
with patch(FETCH, side_effect=Exception("Network error")):
93110
with pytest.raises(ValueError, match="Failed to download PDF"):
94111
PDFLoader().load(SourceContent("https://example.com/report.pdf"))
95112

113+
def test_load_pdf_url_over_size_limit(self):
114+
"""An oversized body is reported rather than partially parsed."""
115+
with patch(FETCH, side_effect=ValueError("exceeds the 1024 byte limit")):
116+
with pytest.raises(ValueError, match="Failed to download PDF"):
117+
PDFLoader().load(SourceContent("https://example.com/huge.pdf"))
118+
96119
def test_load_pdf_missing_file(self):
120+
"""A missing local path raises FileNotFoundError, not ValueError."""
97121
with pytest.raises(FileNotFoundError, match="PDF file not found"):
98122
PDFLoader().load(SourceContent("/nonexistent/report.pdf"))
99123

100124
def test_load_corrupt_pdf_raises_value_error(self):
125+
"""Bytes that are not a parseable PDF produce a read error."""
101126
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
102127
f.write(b"%PDF-1.4 not really a pdf")
103128
f.flush()
@@ -106,6 +131,7 @@ def test_load_corrupt_pdf_raises_value_error(self):
106131
PDFLoader().load(SourceContent(f.name))
107132

108133
def test_pdf_with_no_extractable_text(self):
134+
"""A PDF whose pages hold no text says so instead of returning empty."""
109135
document = pymupdf.open()
110136
document.new_page()
111137
blank = document.tobytes()
@@ -120,6 +146,7 @@ def test_pdf_with_no_extractable_text(self):
120146
assert "no extractable text" in result.content
121147

122148
def test_pdf_doc_id_is_stable(self):
149+
"""The same source yields the same doc_id across loads."""
123150
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
124151
f.write(build_pdf())
125152
f.flush()

0 commit comments

Comments
 (0)