247153575d
Tests / tests (map[TOXENV:py310], macos-latest, 3.10) (push) Has been cancelled
Tests / tests (map[TOXENV:py311], macos-latest, 3.11) (push) Has been cancelled
Tests / tests (map[TOXENV:py312], macos-latest, 3.12) (push) Has been cancelled
Tests / tests (map[TOXENV:py313], macos-latest, 3.13) (push) Has been cancelled
414 lines
15 KiB
Python
414 lines
15 KiB
Python
"""Tests for the Request class."""
|
|
|
|
import pickle
|
|
|
|
import pytest
|
|
|
|
from scrapling.spiders.request import Request
|
|
from scrapling.core._types import Any, Dict, AsyncGenerator
|
|
|
|
|
|
class TestRequestCreation:
|
|
"""Test Request initialization and basic attributes."""
|
|
|
|
def test_basic_request_creation(self):
|
|
"""Test creating a request with just a URL."""
|
|
request = Request("https://example.com")
|
|
|
|
assert request.url == "https://example.com"
|
|
assert request.sid == ""
|
|
assert request.callback is None
|
|
assert request.priority == 0
|
|
assert request.dont_filter is False
|
|
assert request.meta == {}
|
|
assert request._retry_count == 0
|
|
assert request._session_kwargs == {}
|
|
|
|
def test_request_with_all_parameters(self):
|
|
"""Test creating a request with all parameters."""
|
|
|
|
async def my_callback(response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield {"test": "data"}
|
|
|
|
request = Request(
|
|
url="https://example.com/page",
|
|
sid="my_session",
|
|
callback=my_callback,
|
|
priority=10,
|
|
dont_filter=True,
|
|
meta={"key": "value"},
|
|
_retry_count=2,
|
|
proxy="http://proxy:8080",
|
|
timeout=30,
|
|
)
|
|
|
|
assert request.url == "https://example.com/page"
|
|
assert request.sid == "my_session"
|
|
assert request.callback == my_callback
|
|
assert request.priority == 10
|
|
assert request.dont_filter is True
|
|
assert request.meta == {"key": "value"}
|
|
assert request._retry_count == 2
|
|
assert request._session_kwargs == {"proxy": "http://proxy:8080", "timeout": 30}
|
|
|
|
def test_request_meta_default_is_empty_dict(self):
|
|
"""Test that meta defaults to empty dict, not shared reference."""
|
|
r1 = Request("https://example.com")
|
|
r2 = Request("https://example.com")
|
|
|
|
r1.meta["key"] = "value"
|
|
|
|
assert r1.meta == {"key": "value"}
|
|
assert r2.meta == {}
|
|
|
|
|
|
class TestRequestProperties:
|
|
"""Test Request computed properties."""
|
|
|
|
def test_domain_extraction(self):
|
|
"""Test domain property extracts netloc correctly."""
|
|
request = Request("https://www.example.com/path/page.html?query=1")
|
|
assert request.domain == "www.example.com"
|
|
|
|
def test_domain_with_port(self):
|
|
"""Test domain extraction with port number."""
|
|
request = Request("http://localhost:8080/api")
|
|
assert request.domain == "localhost:8080"
|
|
|
|
def test_domain_with_subdomain(self):
|
|
"""Test domain extraction with subdomains."""
|
|
request = Request("https://api.v2.example.com/endpoint")
|
|
assert request.domain == "api.v2.example.com"
|
|
|
|
def test_fingerprint_returns_bytes(self):
|
|
"""Test fingerprint generation returns bytes."""
|
|
request = Request("https://example.com")
|
|
fp = request.update_fingerprint()
|
|
assert isinstance(fp, bytes)
|
|
assert len(fp) == 20 # SHA1 produces 20 bytes
|
|
|
|
def test_fingerprint_is_deterministic(self):
|
|
"""Test same request produces same fingerprint."""
|
|
r1 = Request("https://example.com", data={"key": "value"})
|
|
r2 = Request("https://example.com", data={"key": "value"})
|
|
assert r1.update_fingerprint() == r2.update_fingerprint()
|
|
|
|
def test_fingerprint_different_urls(self):
|
|
"""Test different URLs produce different fingerprints."""
|
|
r1 = Request("https://example.com/page1")
|
|
r2 = Request("https://example.com/page2")
|
|
assert r1.update_fingerprint() != r2.update_fingerprint()
|
|
|
|
def test_fingerprint_include_kwargs_uses_kwarg_values(self):
|
|
"""Test kwargs with different values produce different fingerprints."""
|
|
r1 = Request("https://example.com", timeout=1)
|
|
r2 = Request("https://example.com", timeout=2)
|
|
|
|
assert r1.update_fingerprint(include_kwargs=True) != r2.update_fingerprint(include_kwargs=True)
|
|
|
|
def test_fingerprint_include_kwargs_handles_non_primitive_values(self):
|
|
class _Opaque:
|
|
def __repr__(self) -> str:
|
|
return "_Opaque(stable)"
|
|
|
|
opaque = _Opaque()
|
|
r1 = Request("https://example.com", proxies={"http": "p1"}, custom=opaque)
|
|
r2 = Request("https://example.com", proxies={"http": "p1"}, custom=opaque)
|
|
r3 = Request("https://example.com", proxies={"http": "p2"}, custom=opaque)
|
|
|
|
fp1 = r1.update_fingerprint(include_kwargs=True)
|
|
r2._fp = None
|
|
fp2 = r2.update_fingerprint(include_kwargs=True)
|
|
fp3 = r3.update_fingerprint(include_kwargs=True)
|
|
|
|
assert fp1 == fp2
|
|
assert fp1 != fp3
|
|
|
|
def test_fingerprint_include_headers_preserves_header_value_case(self):
|
|
"""Test header values are fingerprinted without lowercasing."""
|
|
r1 = Request("https://example.com", headers={"X-Test": "A"})
|
|
r2 = Request("https://example.com", headers={"X-Test": "a"})
|
|
|
|
assert r1.update_fingerprint(include_headers=True) != r2.update_fingerprint(include_headers=True)
|
|
|
|
|
|
class TestRequestCopy:
|
|
"""Test Request copy functionality."""
|
|
|
|
def test_copy_creates_independent_request(self):
|
|
"""Test that copy creates a new independent request."""
|
|
|
|
async def callback(response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
original = Request(
|
|
url="https://example.com",
|
|
sid="session",
|
|
callback=callback,
|
|
priority=5,
|
|
dont_filter=True,
|
|
meta={"original": True},
|
|
_retry_count=1,
|
|
proxy="http://proxy:8080",
|
|
)
|
|
|
|
copied = original.copy()
|
|
|
|
# Check all values are copied
|
|
assert copied.url == original.url
|
|
assert copied.sid == original.sid
|
|
assert copied.callback == original.callback
|
|
assert copied.priority == original.priority
|
|
assert copied.dont_filter == original.dont_filter
|
|
assert copied.meta == original.meta
|
|
assert copied._retry_count == original._retry_count
|
|
assert copied._session_kwargs == original._session_kwargs
|
|
|
|
# Check they are different objects
|
|
assert copied is not original
|
|
assert copied.meta is not original.meta # Meta should be a copy
|
|
|
|
def test_copy_meta_is_independent(self):
|
|
"""Test that modifying copied meta doesn't affect original."""
|
|
original = Request("https://example.com", meta={"key": "original"})
|
|
copied = original.copy()
|
|
|
|
copied.meta["key"] = "modified"
|
|
copied.meta["new_key"] = "new_value"
|
|
|
|
assert original.meta == {"key": "original"}
|
|
assert copied.meta == {"key": "modified", "new_key": "new_value"}
|
|
|
|
|
|
class TestRequestComparison:
|
|
"""Test Request comparison operators."""
|
|
|
|
def test_priority_less_than(self):
|
|
"""Test less than comparison by priority."""
|
|
low_priority = Request("https://example.com/1", priority=1)
|
|
high_priority = Request("https://example.com/2", priority=10)
|
|
|
|
assert low_priority < high_priority
|
|
assert not high_priority < low_priority
|
|
|
|
def test_priority_greater_than(self):
|
|
"""Test greater than comparison by priority."""
|
|
low_priority = Request("https://example.com/1", priority=1)
|
|
high_priority = Request("https://example.com/2", priority=10)
|
|
|
|
assert high_priority > low_priority
|
|
assert not low_priority > high_priority
|
|
|
|
def test_equality_by_fingerprint(self):
|
|
"""Test equality comparison by fingerprint."""
|
|
r1 = Request("https://example.com")
|
|
r2 = Request("https://example.com")
|
|
r3 = Request("https://example.com/other")
|
|
|
|
# Generate fingerprints first (required for equality)
|
|
r1.update_fingerprint()
|
|
r2.update_fingerprint()
|
|
r3.update_fingerprint()
|
|
|
|
assert r1 == r2
|
|
assert r1 != r3
|
|
|
|
def test_equality_different_priorities_same_fingerprint(self):
|
|
"""Test requests with same fingerprint are equal despite different priorities."""
|
|
r1 = Request("https://example.com", priority=1)
|
|
r2 = Request("https://example.com", priority=100)
|
|
|
|
# Generate fingerprints first
|
|
r1.update_fingerprint()
|
|
r2.update_fingerprint()
|
|
|
|
assert r1 == r2 # Same fingerprint means equal
|
|
|
|
def test_comparison_with_non_request(self):
|
|
"""Test comparison with non-Request types returns NotImplemented."""
|
|
request = Request("https://example.com")
|
|
|
|
assert request.__lt__("not a request") == NotImplemented
|
|
assert request.__gt__("not a request") == NotImplemented
|
|
assert request.__eq__("not a request") == NotImplemented
|
|
|
|
|
|
class TestRequestStringRepresentation:
|
|
"""Test Request string representations."""
|
|
|
|
def test_str_returns_url(self):
|
|
"""Test __str__ returns the URL."""
|
|
request = Request("https://example.com/page")
|
|
assert str(request) == "https://example.com/page"
|
|
|
|
def test_repr_without_callback(self):
|
|
"""Test __repr__ without callback."""
|
|
request = Request("https://example.com", priority=5)
|
|
repr_str = repr(request)
|
|
|
|
assert "Request" in repr_str
|
|
assert "https://example.com" in repr_str
|
|
assert "priority=5" in repr_str
|
|
assert "callback=None" in repr_str
|
|
|
|
def test_repr_with_callback(self):
|
|
"""Test __repr__ with named callback."""
|
|
|
|
async def my_custom_callback(response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
request = Request("https://example.com", callback=my_custom_callback)
|
|
repr_str = repr(request)
|
|
|
|
assert "callback=my_custom_callback" in repr_str
|
|
|
|
|
|
class TestRequestPickling:
|
|
"""Test Request serialization for checkpointing."""
|
|
|
|
def test_pickle_without_callback(self):
|
|
"""Test pickling request without callback."""
|
|
original = Request(
|
|
url="https://example.com",
|
|
sid="session",
|
|
priority=5,
|
|
meta={"key": "value"},
|
|
)
|
|
|
|
pickled = pickle.dumps(original)
|
|
restored = pickle.loads(pickled)
|
|
|
|
assert restored.url == original.url
|
|
assert restored.sid == original.sid
|
|
assert restored.priority == original.priority
|
|
assert restored.meta == original.meta
|
|
assert restored.callback is None
|
|
|
|
def test_pickle_with_callback_stores_name(self):
|
|
"""Test that callback name is stored when pickling."""
|
|
|
|
async def parse_page(response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield {"data": "test"}
|
|
|
|
original = Request("https://example.com", callback=parse_page)
|
|
|
|
# Check getstate stores callback name
|
|
state = original.__getstate__()
|
|
assert state["_callback_name"] == "parse_page"
|
|
assert state["callback"] is None
|
|
|
|
def test_pickle_with_none_callback(self):
|
|
"""Test pickling with None callback."""
|
|
original = Request("https://example.com", callback=None)
|
|
|
|
state = original.__getstate__()
|
|
assert state["_callback_name"] is None
|
|
assert state["callback"] is None
|
|
|
|
def test_setstate_stores_callback_name(self):
|
|
"""Test that setstate correctly handles callback name."""
|
|
request = Request("https://example.com")
|
|
state = {
|
|
"url": "https://example.com",
|
|
"sid": "",
|
|
"callback": None,
|
|
"priority": 0,
|
|
"dont_filter": False,
|
|
"meta": {},
|
|
"_retry_count": 0,
|
|
"_session_kwargs": {},
|
|
"_callback_name": "custom_parse",
|
|
}
|
|
|
|
request.__setstate__(state)
|
|
|
|
assert hasattr(request, "_callback_name")
|
|
assert request._callback_name == "custom_parse"
|
|
|
|
def test_pickle_roundtrip_preserves_session_kwargs(self):
|
|
"""Test that session kwargs are preserved through pickle."""
|
|
original = Request(
|
|
"https://example.com",
|
|
proxy="http://proxy:8080",
|
|
timeout=30,
|
|
headers={"User-Agent": "test"},
|
|
)
|
|
|
|
pickled = pickle.dumps(original)
|
|
restored = pickle.loads(pickled)
|
|
|
|
assert restored._session_kwargs == {
|
|
"proxy": "http://proxy:8080",
|
|
"timeout": 30,
|
|
"headers": {"User-Agent": "test"},
|
|
}
|
|
|
|
|
|
class TestRequestRestoreCallback:
|
|
"""Test callback restoration from spider."""
|
|
|
|
def test_restore_callback_from_spider(self):
|
|
"""Test restoring callback from spider instance."""
|
|
|
|
class MockSpider:
|
|
async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
async def parse_detail(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield {"detail": True}
|
|
|
|
spider = MockSpider()
|
|
request = Request("https://example.com")
|
|
request._callback_name = "parse_detail"
|
|
|
|
request._restore_callback(spider) # type: ignore[arg-type]
|
|
|
|
assert request.callback == spider.parse_detail
|
|
assert not hasattr(request, "_callback_name")
|
|
|
|
def test_restore_callback_falls_back_to_parse(self):
|
|
"""Test that missing callback falls back to spider.parse."""
|
|
|
|
class MockSpider:
|
|
async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
spider = MockSpider()
|
|
request = Request("https://example.com")
|
|
request._callback_name = "nonexistent_method"
|
|
|
|
request._restore_callback(spider) # type: ignore[arg-type]
|
|
|
|
assert request.callback == spider.parse
|
|
assert not hasattr(request, "_callback_name")
|
|
|
|
def test_restore_callback_with_none_name(self):
|
|
"""Test restore callback when _callback_name is None."""
|
|
|
|
class MockSpider:
|
|
async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
spider = MockSpider()
|
|
request = Request("https://example.com")
|
|
request._callback_name = None
|
|
|
|
request._restore_callback(spider) # type: ignore[arg-type]
|
|
|
|
# Should clean up _callback_name attribute
|
|
assert not hasattr(request, "_callback_name")
|
|
|
|
def test_restore_callback_without_callback_name_attr(self):
|
|
"""Test restore callback when _callback_name attribute doesn't exist."""
|
|
|
|
class MockSpider:
|
|
async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
|
|
yield None
|
|
|
|
spider = MockSpider()
|
|
request = Request("https://example.com")
|
|
# Don't set _callback_name
|
|
|
|
# Should not raise an error
|
|
request._restore_callback(spider) # type: ignore[arg-type]
|