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
109 changes: 109 additions & 0 deletions affine/core/range_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,38 @@ def __init__(self, ranges: List[List[int]]):
"""
self.ranges = self._normalize_ranges(ranges)

@classmethod
def from_ids(cls, ids: List[int]) -> 'RangeSet':
"""Create a RangeSet from a list of individual IDs.

Efficiently converts a list of IDs into merged intervals.
For example, [1, 2, 3, 7, 8, 10] becomes [[1, 4), [7, 9), [10, 11)].

Args:
ids: List of integer IDs (need not be sorted or unique)

Returns:
RangeSet covering exactly the given IDs
"""
if not ids:
return cls([])

sorted_ids = sorted(set(ids))
ranges = []
start = sorted_ids[0]
prev = start

for id_val in sorted_ids[1:]:
if id_val == prev + 1:
prev = id_val
else:
ranges.append([start, prev + 1])
start = id_val
prev = id_val

ranges.append([start, prev + 1])
return cls(ranges)

def _normalize_ranges(self, ranges: List[List[int]]) -> List[Tuple[int, int]]:
"""Normalize ranges: merge overlapping intervals and sort.

Expand Down Expand Up @@ -242,6 +274,83 @@ def to_list(self) -> List[List[int]]:
"""
return [[start, end] for start, end in self.ranges]

def __contains__(self, item: int) -> bool:
"""Check if an ID is contained in any range using binary search.

Args:
item: Integer ID to check

Returns:
True if item is in any range
"""
import bisect

if not self.ranges:
return False

# Binary search for the rightmost range whose start <= item
starts = [r[0] for r in self.ranges]
idx = bisect.bisect_right(starts, item) - 1

if idx < 0:
return False

start, end = self.ranges[idx]
return start <= item < end

def __len__(self) -> int:
"""Return the total number of IDs in all ranges."""
return self.size()

def __eq__(self, other: object) -> bool:
"""Check equality with another RangeSet."""
if not isinstance(other, RangeSet):
return NotImplemented
return self.ranges == other.ranges

def intersection(self, other: 'RangeSet') -> 'RangeSet':
"""Compute the intersection of two RangeSets.

Args:
other: Another RangeSet

Returns:
New RangeSet containing only IDs present in both
"""
result = []
i, j = 0, 0

while i < len(self.ranges) and j < len(other.ranges):
a_start, a_end = self.ranges[i]
b_start, b_end = other.ranges[j]

# Find overlap
overlap_start = max(a_start, b_start)
overlap_end = min(a_end, b_end)

if overlap_start < overlap_end:
result.append([overlap_start, overlap_end])

# Advance the range that ends first
if a_end < b_end:
i += 1
else:
j += 1

return RangeSet(result)

def union(self, other: 'RangeSet') -> 'RangeSet':
"""Compute the union of two RangeSets.

Args:
other: Another RangeSet

Returns:
New RangeSet containing IDs from either set
"""
combined = self.to_list() + other.to_list()
return RangeSet(combined)

def __repr__(self) -> str:
"""String representation for debugging."""
return f"RangeSet({self.to_list()}, size={self.size()})"
42 changes: 42 additions & 0 deletions affine/utils/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,48 @@ async def put(
raise NetworkError(f"Network error during PUT {url}: {e}", url, e)


async def delete(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Any:
"""Make DELETE request to API endpoint.

Args:
endpoint: API endpoint path
params: Optional query parameters
headers: Optional request headers

Returns:
Response data dict on success, empty dict for 204 No Content

Raises:
NetworkError: On network/connection errors
ApiResponseError: On non-2xx response or invalid JSON
"""

url = f"{self.base_url}{endpoint}"
logger.debug(f"DELETE {url}")

try:
async with self._session.delete(url, params=params, headers=headers) as response:
if response.status >= 400:
body = await response.text()
raise ApiResponseError(f"HTTP {response.status}: {body[:200]}", response.status, url, body)

if response.status == 204:
return {}

try:
return await response.json()
except Exception:
raw = await response.text()
raise ApiResponseError(f"Invalid JSON response: {raw[:200]}", response.status, url, raw)

except aiohttp.ClientError as e:
raise NetworkError(f"Network error during DELETE {url}: {e}", url, e)

async def get_chute_info(self, chute_id: str) -> Optional[Dict]:
"""Get chute info from Chutes API.

Expand Down
123 changes: 123 additions & 0 deletions tests/test_api_client_extended.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Extended tests for APIClient - DELETE method and edge cases."""

import pytest
import aiohttp
from unittest.mock import MagicMock, AsyncMock
from affine.utils.api_client import APIClient
from affine.utils.errors import NetworkError, ApiResponseError


class TestAPIClientDelete:
"""Test the DELETE method on APIClient."""

@pytest.mark.asyncio
async def test_delete_success_json(self):
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json.return_value = {"deleted": True}

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.delete.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
result = await client.delete("/items/123")
assert result == {"deleted": True}

@pytest.mark.asyncio
async def test_delete_204_no_content(self):
mock_response = AsyncMock()
mock_response.status = 204

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.delete.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
result = await client.delete("/items/123")
assert result == {}

@pytest.mark.asyncio
async def test_delete_404(self):
mock_response = AsyncMock()
mock_response.status = 404
mock_response.text.return_value = "Not Found"

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.delete.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
with pytest.raises(ApiResponseError) as exc:
await client.delete("/items/999")
assert exc.value.status_code == 404

@pytest.mark.asyncio
async def test_delete_network_error(self):
mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.side_effect = aiohttp.ClientConnectionError("Connection refused")
mock_session.delete.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
with pytest.raises(NetworkError):
await client.delete("/items/1")


class TestAPIClientPut:
"""Test PUT method edge cases."""

@pytest.mark.asyncio
async def test_put_204_no_content(self):
mock_response = AsyncMock()
mock_response.status = 204

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.put.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
result = await client.put("/items/1", json={"name": "updated"})
assert result == {}

@pytest.mark.asyncio
async def test_put_bad_json_response(self):
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json.side_effect = ValueError("Bad JSON")
mock_response.text.return_value = "not json"

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.put.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
with pytest.raises(ApiResponseError) as exc:
await client.put("/items/1")
assert "Invalid JSON" in str(exc.value)


class TestAPIClientPost:
"""Test POST method edge cases."""

@pytest.mark.asyncio
async def test_post_json_error_parsing(self):
"""POST with output_json=True and a JSON-formatted error body."""
mock_response = AsyncMock()
mock_response.status = 422
mock_response.text.return_value = '{"detail": "Validation failed"}'

mock_session = MagicMock()
mock_ctx = MagicMock()
mock_ctx.__aenter__.return_value = mock_response
mock_session.post.return_value = mock_ctx

client = APIClient("http://test.com", mock_session)
with pytest.raises(ApiResponseError) as exc:
await client.post("/submit", output_json=True)
assert exc.value.status_code == 422
Loading