From ac2d743c25b1d4b35acb97aa97b3e7544cdf9852 Mon Sep 17 00:00:00 2001 From: 0xOse <0xosepatrick@gmail.com> Date: Fri, 6 Feb 2026 21:49:40 +0100 Subject: [PATCH] Add HTTP extension module for range downloads Implements private/httpext/httpext.py with range_download() functionality: - HTTPExtClient with HTTP Range header support - Handles 200, 206, and 416 response codes - Network error handling (timeouts, connection errors) - Streaming download to file support - Content-Range header parsing Adds comprehensive unit tests (53 tests, 99% coverage) covering: - Various byte ranges (single byte, suffix ranges, large ranges) - HTTP headers and response handling - Error conditions and edge cases Closes #105 --- private/httpext/__init__.py | 3 + private/httpext/httpext.py | 371 +++++++++++++++++++ tests/unit/test_httpext.py | 700 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1074 insertions(+) create mode 100644 private/httpext/__init__.py create mode 100644 private/httpext/httpext.py create mode 100644 tests/unit/test_httpext.py diff --git a/private/httpext/__init__.py b/private/httpext/__init__.py new file mode 100644 index 0000000..e4b89f5 --- /dev/null +++ b/private/httpext/__init__.py @@ -0,0 +1,3 @@ +from .httpext import HTTPExtClient, RangeDownloadResult, HTTPExtError, RangeNotSatisfiableError + +__all__ = ["HTTPExtClient", "RangeDownloadResult", "HTTPExtError", "RangeNotSatisfiableError"] diff --git a/private/httpext/httpext.py b/private/httpext/httpext.py new file mode 100644 index 0000000..88ed6c5 --- /dev/null +++ b/private/httpext/httpext.py @@ -0,0 +1,371 @@ +""" +HTTP Extension module for range-based downloads. + +This module provides HTTP Range header support for partial content downloads, +enabling efficient retrieval of specific byte ranges from remote resources. +""" + +from dataclasses import dataclass +from typing import Optional, Tuple, BinaryIO +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +class HTTPExtError(Exception): + """Base exception for HTTP extension errors.""" + pass + + +class RangeNotSatisfiableError(HTTPExtError): + """Raised when the requested range cannot be satisfied (HTTP 416).""" + + def __init__(self, message: str, content_length: Optional[int] = None): + super().__init__(message) + self.content_length = content_length + + +class NetworkError(HTTPExtError): + """Raised when a network-related error occurs.""" + pass + + +class InvalidRangeError(HTTPExtError): + """Raised when an invalid range is specified.""" + pass + + +@dataclass +class RangeDownloadResult: + """Result of a range download operation.""" + + data: bytes + start: int + end: int + total_size: Optional[int] + content_length: int + + @property + def is_partial(self) -> bool: + """Returns True if this is a partial content response.""" + return self.total_size is not None and self.content_length < self.total_size + + +class HTTPExtClient: + """ + HTTP client with support for range-based downloads. + + This client handles HTTP Range requests for partial content retrieval, + with proper handling of various response codes and error conditions. + """ + + DEFAULT_TIMEOUT = 30 + DEFAULT_RETRIES = 3 + DEFAULT_BACKOFF_FACTOR = 0.3 + + def __init__( + self, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + ): + """ + Initialize the HTTP extension client. + + Args: + timeout: Request timeout in seconds. + retries: Number of retry attempts for failed requests. + backoff_factor: Backoff factor for retry delays. + """ + self.timeout = timeout + self.session = requests.Session() + + retry_strategy = Retry( + total=retries, + backoff_factor=backoff_factor, + status_forcelist=[500, 502, 503, 504], + allowed_methods=["GET", "HEAD"], + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + def close(self) -> None: + """Close the HTTP session and release resources.""" + self.session.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + + def range_download( + self, + url: str, + start: int, + end: Optional[int] = None, + headers: Optional[dict] = None, + ) -> RangeDownloadResult: + """ + Download a specific byte range from a URL. + + Implements HTTP Range requests as per RFC 7233. Supports both + bounded ranges (start-end) and suffix ranges (start-). + + Args: + url: The URL to download from. + start: The starting byte position (0-indexed). + end: The ending byte position (inclusive). If None, downloads to end of file. + headers: Additional headers to include in the request. + + Returns: + RangeDownloadResult containing the downloaded data and metadata. + + Raises: + InvalidRangeError: If the range parameters are invalid. + RangeNotSatisfiableError: If the server returns 416 (Range Not Satisfiable). + NetworkError: If a network error occurs. + HTTPExtError: For other HTTP errors. + """ + self._validate_range(start, end) + + range_header = self._build_range_header(start, end) + request_headers = {"Range": range_header} + + if headers: + request_headers.update(headers) + + try: + response = self.session.get( + url, + headers=request_headers, + timeout=self.timeout, + ) + + return self._handle_response(response, start, end) + + except requests.exceptions.Timeout as e: + raise NetworkError(f"Request timed out: {e}") from e + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection error: {e}") from e + except requests.exceptions.RequestException as e: + raise HTTPExtError(f"Request failed: {e}") from e + + def range_download_to_file( + self, + url: str, + start: int, + end: Optional[int], + writer: BinaryIO, + headers: Optional[dict] = None, + chunk_size: int = 8192, + ) -> RangeDownloadResult: + """ + Download a specific byte range from a URL directly to a file. + + This method streams the response to avoid loading large ranges into memory. + + Args: + url: The URL to download from. + start: The starting byte position (0-indexed). + end: The ending byte position (inclusive). If None, downloads to end of file. + writer: A binary file-like object to write the data to. + headers: Additional headers to include in the request. + chunk_size: Size of chunks to read/write at a time. + + Returns: + RangeDownloadResult containing metadata (data field will be empty). + + Raises: + InvalidRangeError: If the range parameters are invalid. + RangeNotSatisfiableError: If the server returns 416 (Range Not Satisfiable). + NetworkError: If a network error occurs. + HTTPExtError: For other HTTP errors. + """ + self._validate_range(start, end) + + range_header = self._build_range_header(start, end) + request_headers = {"Range": range_header} + + if headers: + request_headers.update(headers) + + try: + response = self.session.get( + url, + headers=request_headers, + timeout=self.timeout, + stream=True, + ) + + # Handle error responses before streaming + if response.status_code == 416: + content_length = self._parse_content_length_from_416(response) + raise RangeNotSatisfiableError( + f"Range not satisfiable: {start}-{end}", + content_length=content_length, + ) + + if response.status_code not in (200, 206): + raise HTTPExtError( + f"Unexpected status code: {response.status_code}" + ) + + # Stream content to writer + bytes_written = 0 + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + writer.write(chunk) + bytes_written += len(chunk) + + # Parse content range + actual_start, actual_end, total_size = self._parse_content_range( + response.headers.get("Content-Range") + ) + + return RangeDownloadResult( + data=b"", + start=actual_start if actual_start is not None else start, + end=actual_end if actual_end is not None else start + bytes_written - 1, + total_size=total_size, + content_length=bytes_written, + ) + + except requests.exceptions.Timeout as e: + raise NetworkError(f"Request timed out: {e}") from e + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection error: {e}") from e + except requests.exceptions.RequestException as e: + raise HTTPExtError(f"Request failed: {e}") from e + + def get_content_length(self, url: str, headers: Optional[dict] = None) -> Optional[int]: + """ + Get the content length of a resource using a HEAD request. + + Args: + url: The URL to query. + headers: Additional headers to include in the request. + + Returns: + The content length in bytes, or None if not available. + + Raises: + NetworkError: If a network error occurs. + HTTPExtError: For other HTTP errors. + """ + try: + response = self.session.head(url, headers=headers, timeout=self.timeout) + response.raise_for_status() + + content_length = response.headers.get("Content-Length") + if content_length: + return int(content_length) + return None + + except requests.exceptions.Timeout as e: + raise NetworkError(f"Request timed out: {e}") from e + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection error: {e}") from e + except requests.exceptions.RequestException as e: + raise HTTPExtError(f"Request failed: {e}") from e + + def _validate_range(self, start: int, end: Optional[int]) -> None: + """Validate range parameters.""" + if start < 0: + raise InvalidRangeError("Start position cannot be negative") + + if end is not None: + if end < 0: + raise InvalidRangeError("End position cannot be negative") + if end < start: + raise InvalidRangeError("End position cannot be less than start position") + + def _build_range_header(self, start: int, end: Optional[int]) -> str: + """Build the HTTP Range header value.""" + if end is not None: + return f"bytes={start}-{end}" + return f"bytes={start}-" + + def _handle_response( + self, response: requests.Response, start: int, end: Optional[int] + ) -> RangeDownloadResult: + """Handle the HTTP response and build the result.""" + if response.status_code == 416: + content_length = self._parse_content_length_from_416(response) + raise RangeNotSatisfiableError( + f"Range not satisfiable: {start}-{end}", + content_length=content_length, + ) + + if response.status_code == 206: + # Partial content - parse Content-Range header + actual_start, actual_end, total_size = self._parse_content_range( + response.headers.get("Content-Range") + ) + + return RangeDownloadResult( + data=response.content, + start=actual_start if actual_start is not None else start, + end=actual_end if actual_end is not None else start + len(response.content) - 1, + total_size=total_size, + content_length=len(response.content), + ) + + if response.status_code == 200: + # Server doesn't support range requests, returned full content + return RangeDownloadResult( + data=response.content, + start=0, + end=len(response.content) - 1, + total_size=len(response.content), + content_length=len(response.content), + ) + + raise HTTPExtError(f"Unexpected status code: {response.status_code}") + + def _parse_content_range( + self, content_range: Optional[str] + ) -> Tuple[Optional[int], Optional[int], Optional[int]]: + """ + Parse the Content-Range header. + + Format: bytes start-end/total or bytes start-end/* + + Returns: + Tuple of (start, end, total_size). total_size may be None if unknown. + """ + if not content_range: + return None, None, None + + try: + # Format: "bytes start-end/total" + if not content_range.startswith("bytes "): + return None, None, None + + range_part = content_range[6:] # Remove "bytes " + range_spec, size_spec = range_part.split("/") + + start_str, end_str = range_spec.split("-") + start = int(start_str) + end = int(end_str) + + total_size = None if size_spec == "*" else int(size_spec) + + return start, end, total_size + except (ValueError, IndexError): + return None, None, None + + def _parse_content_length_from_416(self, response: requests.Response) -> Optional[int]: + """Extract content length from a 416 response if available.""" + content_range = response.headers.get("Content-Range") + if content_range: + # Format might be "bytes */total" + try: + if content_range.startswith("bytes */"): + return int(content_range[8:]) + except ValueError: + pass + return None diff --git a/tests/unit/test_httpext.py b/tests/unit/test_httpext.py new file mode 100644 index 0000000..5a79035 --- /dev/null +++ b/tests/unit/test_httpext.py @@ -0,0 +1,700 @@ +""" +Unit tests for the HTTP Extension module (private/httpext/httpext.py). + +Tests cover: +- range_download() with various byte ranges +- HTTP headers handling +- Response handling (200, 206, 416) +- Network errors +- Edge cases +""" + +import io +import pytest +from unittest.mock import Mock, patch, MagicMock +import requests + +from private.httpext import ( + HTTPExtClient, + RangeDownloadResult, + HTTPExtError, + RangeNotSatisfiableError, +) +from private.httpext.httpext import NetworkError, InvalidRangeError + + +class TestHTTPExtClientInit: + """Tests for HTTPExtClient initialization.""" + + def test_init_default_values(self): + """Test client initialization with default values.""" + client = HTTPExtClient() + assert client.timeout == HTTPExtClient.DEFAULT_TIMEOUT + assert client.session is not None + client.close() + + def test_init_custom_values(self): + """Test client initialization with custom values.""" + client = HTTPExtClient(timeout=60, retries=5, backoff_factor=0.5) + assert client.timeout == 60 + client.close() + + def test_context_manager(self): + """Test client as context manager.""" + with HTTPExtClient() as client: + assert client.session is not None + # Session should be closed after context exits + + def test_close(self): + """Test client close method.""" + client = HTTPExtClient() + client.close() + # Should not raise even if called multiple times + client.close() + + +class TestRangeValidation: + """Tests for range parameter validation.""" + + def test_validate_range_valid(self): + """Test validation passes for valid ranges.""" + client = HTTPExtClient() + # Should not raise + client._validate_range(0, 100) + client._validate_range(0, None) + client._validate_range(100, 200) + client._validate_range(0, 0) # Single byte + client.close() + + def test_validate_range_negative_start(self): + """Test validation fails for negative start.""" + client = HTTPExtClient() + with pytest.raises(InvalidRangeError, match="Start position cannot be negative"): + client._validate_range(-1, 100) + client.close() + + def test_validate_range_negative_end(self): + """Test validation fails for negative end.""" + client = HTTPExtClient() + with pytest.raises(InvalidRangeError, match="End position cannot be negative"): + client._validate_range(0, -1) + client.close() + + def test_validate_range_end_less_than_start(self): + """Test validation fails when end < start.""" + client = HTTPExtClient() + with pytest.raises(InvalidRangeError, match="End position cannot be less than start"): + client._validate_range(100, 50) + client.close() + + +class TestBuildRangeHeader: + """Tests for Range header construction.""" + + def test_build_range_header_with_end(self): + """Test Range header with both start and end.""" + client = HTTPExtClient() + header = client._build_range_header(0, 499) + assert header == "bytes=0-499" + client.close() + + def test_build_range_header_without_end(self): + """Test Range header with only start (suffix range).""" + client = HTTPExtClient() + header = client._build_range_header(500, None) + assert header == "bytes=500-" + client.close() + + def test_build_range_header_single_byte(self): + """Test Range header for single byte.""" + client = HTTPExtClient() + header = client._build_range_header(100, 100) + assert header == "bytes=100-100" + client.close() + + def test_build_range_header_large_range(self): + """Test Range header for large range.""" + client = HTTPExtClient() + header = client._build_range_header(0, 1073741823) # ~1GB + assert header == "bytes=0-1073741823" + client.close() + + +class TestParseContentRange: + """Tests for Content-Range header parsing.""" + + def test_parse_content_range_valid(self): + """Test parsing valid Content-Range header.""" + client = HTTPExtClient() + start, end, total = client._parse_content_range("bytes 0-499/1000") + assert start == 0 + assert end == 499 + assert total == 1000 + client.close() + + def test_parse_content_range_unknown_total(self): + """Test parsing Content-Range with unknown total.""" + client = HTTPExtClient() + start, end, total = client._parse_content_range("bytes 0-499/*") + assert start == 0 + assert end == 499 + assert total is None + client.close() + + def test_parse_content_range_none(self): + """Test parsing None Content-Range.""" + client = HTTPExtClient() + start, end, total = client._parse_content_range(None) + assert start is None + assert end is None + assert total is None + client.close() + + def test_parse_content_range_invalid_format(self): + """Test parsing invalid Content-Range format.""" + client = HTTPExtClient() + # Missing "bytes " prefix + start, end, total = client._parse_content_range("0-499/1000") + assert start is None + + # Invalid range format + start, end, total = client._parse_content_range("bytes invalid") + assert start is None + client.close() + + +class TestRangeDownload: + """Tests for range_download() method.""" + + @patch.object(requests.Session, 'get') + def test_range_download_206_partial_content(self, mock_get): + """Test successful range download with 206 Partial Content.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"Hello" + mock_response.headers = {"Content-Range": "bytes 0-4/1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, 4) + + assert result.data == b"Hello" + assert result.start == 0 + assert result.end == 4 + assert result.total_size == 1000 + assert result.content_length == 5 + assert result.is_partial is True + + # Verify Range header was sent + mock_get.assert_called_once() + call_headers = mock_get.call_args[1]["headers"] + assert call_headers["Range"] == "bytes=0-4" + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_200_full_content(self, mock_get): + """Test range download when server returns full content (200).""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"Full file content" + mock_response.headers = {} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, 4) + + assert result.data == b"Full file content" + assert result.start == 0 + assert result.end == len(b"Full file content") - 1 + assert result.total_size == len(b"Full file content") + assert result.is_partial is False + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_416_range_not_satisfiable(self, mock_get): + """Test range download with 416 Range Not Satisfiable.""" + mock_response = Mock() + mock_response.status_code = 416 + mock_response.headers = {"Content-Range": "bytes */1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + with pytest.raises(RangeNotSatisfiableError) as exc_info: + client.range_download("http://example.com/file", 2000, 3000) + + assert exc_info.value.content_length == 1000 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_416_without_content_length(self, mock_get): + """Test 416 response without content length info.""" + mock_response = Mock() + mock_response.status_code = 416 + mock_response.headers = {} + mock_get.return_value = mock_response + + client = HTTPExtClient() + with pytest.raises(RangeNotSatisfiableError) as exc_info: + client.range_download("http://example.com/file", 2000, 3000) + + assert exc_info.value.content_length is None + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_unexpected_status(self, mock_get): + """Test range download with unexpected status code.""" + mock_response = Mock() + mock_response.status_code = 403 + mock_get.return_value = mock_response + + client = HTTPExtClient() + with pytest.raises(HTTPExtError, match="Unexpected status code: 403"): + client.range_download("http://example.com/file", 0, 100) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_with_custom_headers(self, mock_get): + """Test range download with additional custom headers.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"data" + mock_response.headers = {"Content-Range": "bytes 0-3/100"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download( + "http://example.com/file", + 0, 3, + headers={"Authorization": "Bearer token123"} + ) + + call_headers = mock_get.call_args[1]["headers"] + assert call_headers["Authorization"] == "Bearer token123" + assert call_headers["Range"] == "bytes=0-3" + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_suffix_range(self, mock_get): + """Test range download with suffix range (no end specified).""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"end of file" + mock_response.headers = {"Content-Range": "bytes 900-999/1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 900, None) + + call_headers = mock_get.call_args[1]["headers"] + assert call_headers["Range"] == "bytes=900-" + assert result.start == 900 + assert result.end == 999 + client.close() + + +class TestRangeDownloadNetworkErrors: + """Tests for network error handling in range_download().""" + + @patch.object(requests.Session, 'get') + def test_range_download_timeout(self, mock_get): + """Test range download timeout handling.""" + mock_get.side_effect = requests.exceptions.Timeout("Connection timed out") + + client = HTTPExtClient() + with pytest.raises(NetworkError, match="Request timed out"): + client.range_download("http://example.com/file", 0, 100) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_connection_error(self, mock_get): + """Test range download connection error handling.""" + mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused") + + client = HTTPExtClient() + with pytest.raises(NetworkError, match="Connection error"): + client.range_download("http://example.com/file", 0, 100) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_request_exception(self, mock_get): + """Test range download generic request exception.""" + mock_get.side_effect = requests.exceptions.RequestException("Unknown error") + + client = HTTPExtClient() + with pytest.raises(HTTPExtError, match="Request failed"): + client.range_download("http://example.com/file", 0, 100) + client.close() + + +class TestRangeDownloadEdgeCases: + """Tests for edge cases in range_download().""" + + @patch.object(requests.Session, 'get') + def test_range_download_single_byte(self, mock_get): + """Test downloading a single byte.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"X" + mock_response.headers = {"Content-Range": "bytes 50-50/1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 50, 50) + + assert result.data == b"X" + assert result.start == 50 + assert result.end == 50 + assert result.content_length == 1 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_first_byte(self, mock_get): + """Test downloading the first byte only.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"F" + mock_response.headers = {"Content-Range": "bytes 0-0/1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, 0) + + assert result.data == b"F" + assert result.start == 0 + assert result.end == 0 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_last_byte(self, mock_get): + """Test downloading the last byte only.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"L" + mock_response.headers = {"Content-Range": "bytes 999-999/1000"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 999, 999) + + assert result.data == b"L" + assert result.end == 999 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_empty_response(self, mock_get): + """Test handling empty response content.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"" + mock_response.headers = {"Content-Range": "bytes 0-0/0"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, 0) + + assert result.data == b"" + assert result.content_length == 0 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_large_range(self, mock_get): + """Test downloading a large range.""" + large_data = b"X" * 1024 * 1024 # 1MB + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = large_data + mock_response.headers = {"Content-Range": f"bytes 0-{len(large_data)-1}/{len(large_data)}"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, len(large_data) - 1) + + assert len(result.data) == len(large_data) + assert result.content_length == len(large_data) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_missing_content_range_header(self, mock_get): + """Test 206 response without Content-Range header.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.content = b"data" + mock_response.headers = {} + mock_get.return_value = mock_response + + client = HTTPExtClient() + result = client.range_download("http://example.com/file", 0, 3) + + # Should use fallback values + assert result.data == b"data" + assert result.start == 0 + assert result.end == 3 # start + len(content) - 1 + assert result.total_size is None + client.close() + + def test_range_download_invalid_start(self): + """Test range download with invalid start position.""" + client = HTTPExtClient() + with pytest.raises(InvalidRangeError): + client.range_download("http://example.com/file", -1, 100) + client.close() + + def test_range_download_invalid_end(self): + """Test range download with end < start.""" + client = HTTPExtClient() + with pytest.raises(InvalidRangeError): + client.range_download("http://example.com/file", 100, 50) + client.close() + + +class TestRangeDownloadToFile: + """Tests for range_download_to_file() method.""" + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_success(self, mock_get): + """Test successful streaming download to file.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.headers = {"Content-Range": "bytes 0-99/1000"} + mock_response.iter_content = Mock(return_value=[b"chunk1", b"chunk2", b"chunk3"]) + mock_get.return_value = mock_response + + client = HTTPExtClient() + buffer = io.BytesIO() + + result = client.range_download_to_file( + "http://example.com/file", 0, 99, buffer + ) + + assert buffer.getvalue() == b"chunk1chunk2chunk3" + assert result.start == 0 + assert result.end == 99 + assert result.total_size == 1000 + assert result.data == b"" # Data written to file, not returned + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_416(self, mock_get): + """Test streaming download with 416 response.""" + mock_response = Mock() + mock_response.status_code = 416 + mock_response.headers = {"Content-Range": "bytes */500"} + mock_get.return_value = mock_response + + client = HTTPExtClient() + buffer = io.BytesIO() + + with pytest.raises(RangeNotSatisfiableError) as exc_info: + client.range_download_to_file( + "http://example.com/file", 1000, 2000, buffer + ) + + assert exc_info.value.content_length == 500 + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_timeout(self, mock_get): + """Test streaming download timeout.""" + mock_get.side_effect = requests.exceptions.Timeout() + + client = HTTPExtClient() + buffer = io.BytesIO() + + with pytest.raises(NetworkError, match="Request timed out"): + client.range_download_to_file( + "http://example.com/file", 0, 100, buffer + ) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_unexpected_status(self, mock_get): + """Test streaming download with unexpected status code.""" + mock_response = Mock() + mock_response.status_code = 403 + mock_response.headers = {} + mock_get.return_value = mock_response + + client = HTTPExtClient() + buffer = io.BytesIO() + + with pytest.raises(HTTPExtError, match="Unexpected status code: 403"): + client.range_download_to_file( + "http://example.com/file", 0, 100, buffer + ) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_connection_error(self, mock_get): + """Test streaming download connection error.""" + mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused") + + client = HTTPExtClient() + buffer = io.BytesIO() + + with pytest.raises(NetworkError, match="Connection error"): + client.range_download_to_file( + "http://example.com/file", 0, 100, buffer + ) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_request_exception(self, mock_get): + """Test streaming download generic request exception.""" + mock_get.side_effect = requests.exceptions.RequestException("Unknown error") + + client = HTTPExtClient() + buffer = io.BytesIO() + + with pytest.raises(HTTPExtError, match="Request failed"): + client.range_download_to_file( + "http://example.com/file", 0, 100, buffer + ) + client.close() + + @patch.object(requests.Session, 'get') + def test_range_download_to_file_with_custom_headers(self, mock_get): + """Test streaming download with custom headers.""" + mock_response = Mock() + mock_response.status_code = 206 + mock_response.headers = {"Content-Range": "bytes 0-99/1000"} + mock_response.iter_content = Mock(return_value=[b"data"]) + mock_get.return_value = mock_response + + client = HTTPExtClient() + buffer = io.BytesIO() + + client.range_download_to_file( + "http://example.com/file", 0, 99, buffer, + headers={"Authorization": "Bearer token"} + ) + + call_headers = mock_get.call_args[1]["headers"] + assert call_headers["Authorization"] == "Bearer token" + assert call_headers["Range"] == "bytes=0-99" + client.close() + + +class TestGetContentLength: + """Tests for get_content_length() method.""" + + @patch.object(requests.Session, 'head') + def test_get_content_length_success(self, mock_head): + """Test successful content length retrieval.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Length": "12345"} + mock_response.raise_for_status = Mock() + mock_head.return_value = mock_response + + client = HTTPExtClient() + length = client.get_content_length("http://example.com/file") + + assert length == 12345 + client.close() + + @patch.object(requests.Session, 'head') + def test_get_content_length_not_available(self, mock_head): + """Test when Content-Length header is not available.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.raise_for_status = Mock() + mock_head.return_value = mock_response + + client = HTTPExtClient() + length = client.get_content_length("http://example.com/file") + + assert length is None + client.close() + + @patch.object(requests.Session, 'head') + def test_get_content_length_network_error(self, mock_head): + """Test content length with network error.""" + mock_head.side_effect = requests.exceptions.ConnectionError() + + client = HTTPExtClient() + with pytest.raises(NetworkError, match="Connection error"): + client.get_content_length("http://example.com/file") + client.close() + + @patch.object(requests.Session, 'head') + def test_get_content_length_timeout(self, mock_head): + """Test content length with timeout error.""" + mock_head.side_effect = requests.exceptions.Timeout() + + client = HTTPExtClient() + with pytest.raises(NetworkError, match="Request timed out"): + client.get_content_length("http://example.com/file") + client.close() + + @patch.object(requests.Session, 'head') + def test_get_content_length_request_exception(self, mock_head): + """Test content length with generic request exception.""" + mock_head.side_effect = requests.exceptions.RequestException("Unknown") + + client = HTTPExtClient() + with pytest.raises(HTTPExtError, match="Request failed"): + client.get_content_length("http://example.com/file") + client.close() + + +class TestRangeDownloadResult: + """Tests for RangeDownloadResult dataclass.""" + + def test_is_partial_true(self): + """Test is_partial returns True for partial content.""" + result = RangeDownloadResult( + data=b"data", + start=0, + end=99, + total_size=1000, + content_length=100, + ) + assert result.is_partial is True + + def test_is_partial_false_full_content(self): + """Test is_partial returns False for full content.""" + result = RangeDownloadResult( + data=b"full", + start=0, + end=3, + total_size=4, + content_length=4, + ) + assert result.is_partial is False + + def test_is_partial_false_unknown_total(self): + """Test is_partial returns False when total_size is unknown.""" + result = RangeDownloadResult( + data=b"data", + start=0, + end=99, + total_size=None, + content_length=100, + ) + assert result.is_partial is False + + +class TestHTTPExtErrorHierarchy: + """Tests for exception hierarchy.""" + + def test_range_not_satisfiable_is_httpext_error(self): + """Test RangeNotSatisfiableError inherits from HTTPExtError.""" + error = RangeNotSatisfiableError("test") + assert isinstance(error, HTTPExtError) + + def test_network_error_is_httpext_error(self): + """Test NetworkError inherits from HTTPExtError.""" + error = NetworkError("test") + assert isinstance(error, HTTPExtError) + + def test_invalid_range_error_is_httpext_error(self): + """Test InvalidRangeError inherits from HTTPExtError.""" + error = InvalidRangeError("test") + assert isinstance(error, HTTPExtError) + + def test_range_not_satisfiable_with_content_length(self): + """Test RangeNotSatisfiableError stores content_length.""" + error = RangeNotSatisfiableError("test", content_length=1000) + assert error.content_length == 1000 + assert str(error) == "test"