lojack-api 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lojack_api/__init__.py +73 -0
- lojack_api/api.py +465 -0
- lojack_api/auth.py +285 -0
- lojack_api/device.py +343 -0
- lojack_api/exceptions.py +93 -0
- lojack_api/models.py +285 -0
- lojack_api/py.typed +0 -0
- lojack_api/transport.py +184 -0
- lojack_api-0.5.0.dist-info/METADATA +317 -0
- lojack_api-0.5.0.dist-info/RECORD +13 -0
- lojack_api-0.5.0.dist-info/WHEEL +5 -0
- lojack_api-0.5.0.dist-info/licenses/LICENSE +14 -0
- lojack_api-0.5.0.dist-info/top_level.txt +1 -0
lojack_api/exceptions.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Exceptions for the LoJack client library."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LoJackError(Exception):
|
|
7
|
+
"""Base exception for all LoJack client errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str = "", *args: Any) -> None:
|
|
10
|
+
self.message = message
|
|
11
|
+
super().__init__(message, *args)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(LoJackError):
|
|
15
|
+
"""Raised when authentication fails (invalid credentials, expired token, etc.)."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthorizationError(LoJackError):
|
|
21
|
+
"""Raised when the user is not authorized to perform an action."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiError(LoJackError):
|
|
27
|
+
"""Raised when the API returns an error response."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str = "",
|
|
32
|
+
status_code: int | None = None,
|
|
33
|
+
response_body: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.response_body = response_body
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
if self.status_code:
|
|
41
|
+
return f"{self.message} (HTTP {self.status_code})"
|
|
42
|
+
return self.message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConnectionError(LoJackError):
|
|
46
|
+
"""Raised when a connection to the API cannot be established."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TimeoutError(LoJackError):
|
|
52
|
+
"""Raised when an API request times out."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DeviceNotFoundError(LoJackError):
|
|
58
|
+
"""Raised when a requested device is not found."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, device_id: str, message: str | None = None) -> None:
|
|
61
|
+
self.device_id = device_id
|
|
62
|
+
super().__init__(message or f"Device not found: {device_id}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CommandError(LoJackError):
|
|
66
|
+
"""Raised when a device command fails."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
command: str,
|
|
71
|
+
device_id: str,
|
|
72
|
+
message: str | None = None,
|
|
73
|
+
reason: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.command = command
|
|
76
|
+
self.device_id = device_id
|
|
77
|
+
self.reason = reason
|
|
78
|
+
super().__init__(
|
|
79
|
+
message or f"Command '{command}' failed for device {device_id}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class InvalidParameterError(LoJackError):
|
|
84
|
+
"""Raised when an invalid parameter is provided."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, parameter: str, value: Any, reason: str | None = None) -> None:
|
|
87
|
+
self.parameter = parameter
|
|
88
|
+
self.value = value
|
|
89
|
+
self.reason = reason
|
|
90
|
+
msg = f"Invalid parameter '{parameter}': {value}"
|
|
91
|
+
if reason:
|
|
92
|
+
msg += f" ({reason})"
|
|
93
|
+
super().__init__(msg)
|
lojack_api/models.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Data models for LoJack API responses.
|
|
2
|
+
|
|
3
|
+
These are simple dataclasses representing the raw data from the API.
|
|
4
|
+
For objects with methods (refresh, lock, etc.), see device.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Location:
|
|
16
|
+
"""A single location point from the API."""
|
|
17
|
+
|
|
18
|
+
latitude: float | None = None
|
|
19
|
+
longitude: float | None = None
|
|
20
|
+
timestamp: datetime | None = None
|
|
21
|
+
accuracy: float | None = None
|
|
22
|
+
speed: float | None = None
|
|
23
|
+
heading: float | None = None
|
|
24
|
+
address: str | None = None
|
|
25
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_api(cls, data: dict[str, Any]) -> Location:
|
|
29
|
+
"""Parse a location from API response data."""
|
|
30
|
+
loc = cls(raw=data)
|
|
31
|
+
|
|
32
|
+
# Handle nested coordinates (Spireon format)
|
|
33
|
+
coords = data.get("coordinates", {})
|
|
34
|
+
|
|
35
|
+
loc.latitude = (
|
|
36
|
+
data.get("latitude")
|
|
37
|
+
or data.get("lat")
|
|
38
|
+
or coords.get("latitude")
|
|
39
|
+
or coords.get("lat")
|
|
40
|
+
)
|
|
41
|
+
loc.longitude = (
|
|
42
|
+
data.get("longitude")
|
|
43
|
+
or data.get("lng")
|
|
44
|
+
or data.get("lon")
|
|
45
|
+
or coords.get("longitude")
|
|
46
|
+
or coords.get("lng")
|
|
47
|
+
)
|
|
48
|
+
loc.accuracy = data.get("accuracy") or data.get("hdop")
|
|
49
|
+
loc.speed = data.get("speed")
|
|
50
|
+
loc.heading = data.get("heading") or data.get("bearing") or data.get("course")
|
|
51
|
+
loc.address = data.get("address") or data.get("formattedAddress")
|
|
52
|
+
|
|
53
|
+
# Parse timestamp - try multiple formats
|
|
54
|
+
ts = (
|
|
55
|
+
data.get("timestamp")
|
|
56
|
+
or data.get("time")
|
|
57
|
+
or data.get("recorded_at")
|
|
58
|
+
or data.get("eventDateTime")
|
|
59
|
+
or data.get("dateTime")
|
|
60
|
+
)
|
|
61
|
+
if ts:
|
|
62
|
+
loc.timestamp = _parse_timestamp(ts)
|
|
63
|
+
|
|
64
|
+
return loc
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_event(cls, event_data: dict[str, Any]) -> Location:
|
|
68
|
+
"""Parse a location from a Spireon event.
|
|
69
|
+
|
|
70
|
+
Spireon events have a nested structure:
|
|
71
|
+
{
|
|
72
|
+
"location": {"lat": 40.7128, "lng": -74.0060},
|
|
73
|
+
"heading": 180,
|
|
74
|
+
"speed": 35,
|
|
75
|
+
"date": "2024-01-15T12:00:00.000+0000",
|
|
76
|
+
...
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
loc = cls(raw=event_data)
|
|
80
|
+
|
|
81
|
+
# Get nested location object
|
|
82
|
+
location_data = event_data.get("location", {})
|
|
83
|
+
|
|
84
|
+
# Parse coordinates from nested location or top-level
|
|
85
|
+
loc.latitude = (
|
|
86
|
+
location_data.get("lat")
|
|
87
|
+
or location_data.get("latitude")
|
|
88
|
+
or event_data.get("lat")
|
|
89
|
+
or event_data.get("latitude")
|
|
90
|
+
)
|
|
91
|
+
loc.longitude = (
|
|
92
|
+
location_data.get("lng")
|
|
93
|
+
or location_data.get("lon")
|
|
94
|
+
or location_data.get("longitude")
|
|
95
|
+
or event_data.get("lng")
|
|
96
|
+
or event_data.get("lon")
|
|
97
|
+
or event_data.get("longitude")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Speed and heading are at top level in events
|
|
101
|
+
loc.speed = event_data.get("speed")
|
|
102
|
+
loc.heading = (
|
|
103
|
+
event_data.get("heading")
|
|
104
|
+
or event_data.get("bearing")
|
|
105
|
+
or event_data.get("course")
|
|
106
|
+
)
|
|
107
|
+
loc.accuracy = event_data.get("accuracy") or event_data.get("hdop")
|
|
108
|
+
loc.address = event_data.get("address") or event_data.get("formattedAddress")
|
|
109
|
+
|
|
110
|
+
# Parse timestamp - events use "date" field
|
|
111
|
+
ts = (
|
|
112
|
+
event_data.get("date")
|
|
113
|
+
or event_data.get("eventDateTime")
|
|
114
|
+
or event_data.get("dateTime")
|
|
115
|
+
or event_data.get("timestamp")
|
|
116
|
+
)
|
|
117
|
+
if ts:
|
|
118
|
+
loc.timestamp = _parse_timestamp(ts)
|
|
119
|
+
|
|
120
|
+
return loc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class DeviceInfo:
|
|
125
|
+
"""Basic device information from the API."""
|
|
126
|
+
|
|
127
|
+
id: str
|
|
128
|
+
name: str | None = None
|
|
129
|
+
device_type: str | None = None
|
|
130
|
+
status: str | None = None
|
|
131
|
+
last_seen: datetime | None = None
|
|
132
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_api(cls, data: dict[str, Any]) -> DeviceInfo:
|
|
136
|
+
"""Parse device info from API response data."""
|
|
137
|
+
# Handle Spireon's nested "attributes" structure
|
|
138
|
+
attrs = data.get("attributes", {})
|
|
139
|
+
status_obj = data.get("status", {})
|
|
140
|
+
|
|
141
|
+
device = cls(
|
|
142
|
+
id=data.get("id") or data.get("device_id") or data.get("assetId") or "",
|
|
143
|
+
name=data.get("name") or attrs.get("name") or data.get("device_name"),
|
|
144
|
+
device_type=data.get("type") or attrs.get("type") or data.get("device_type"),
|
|
145
|
+
status=(
|
|
146
|
+
status_obj.get("status")
|
|
147
|
+
if isinstance(status_obj, dict)
|
|
148
|
+
else data.get("status")
|
|
149
|
+
),
|
|
150
|
+
raw=data,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
ts = (
|
|
154
|
+
data.get("last_seen")
|
|
155
|
+
or data.get("lastSeen")
|
|
156
|
+
or data.get("last_updated")
|
|
157
|
+
or data.get("lastEventDateTime")
|
|
158
|
+
)
|
|
159
|
+
if ts:
|
|
160
|
+
device.last_seen = _parse_timestamp(ts)
|
|
161
|
+
|
|
162
|
+
return device
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class VehicleInfo(DeviceInfo):
|
|
167
|
+
"""Vehicle-specific information extending DeviceInfo."""
|
|
168
|
+
|
|
169
|
+
vin: str | None = None
|
|
170
|
+
make: str | None = None
|
|
171
|
+
model: str | None = None
|
|
172
|
+
year: int | None = None
|
|
173
|
+
license_plate: str | None = None
|
|
174
|
+
odometer: float | None = None
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_api(cls, data: dict[str, Any]) -> VehicleInfo:
|
|
178
|
+
"""Parse vehicle info from API response data."""
|
|
179
|
+
# Handle Spireon's nested "attributes" structure
|
|
180
|
+
attrs = data.get("attributes", {})
|
|
181
|
+
status_obj = data.get("status", {})
|
|
182
|
+
|
|
183
|
+
vehicle = cls(
|
|
184
|
+
id=(
|
|
185
|
+
data.get("id")
|
|
186
|
+
or data.get("device_id")
|
|
187
|
+
or data.get("vehicle_id")
|
|
188
|
+
or data.get("assetId")
|
|
189
|
+
or ""
|
|
190
|
+
),
|
|
191
|
+
name=data.get("name") or attrs.get("name") or data.get("vehicle_name"),
|
|
192
|
+
device_type=(
|
|
193
|
+
data.get("type")
|
|
194
|
+
or attrs.get("type")
|
|
195
|
+
or data.get("device_type")
|
|
196
|
+
or "vehicle"
|
|
197
|
+
),
|
|
198
|
+
status=(
|
|
199
|
+
status_obj.get("status")
|
|
200
|
+
if isinstance(status_obj, dict)
|
|
201
|
+
else data.get("status")
|
|
202
|
+
),
|
|
203
|
+
raw=data,
|
|
204
|
+
vin=data.get("vin") or attrs.get("vin"),
|
|
205
|
+
make=data.get("make") or attrs.get("make"),
|
|
206
|
+
model=data.get("model") or attrs.get("model"),
|
|
207
|
+
license_plate=(
|
|
208
|
+
data.get("license_plate")
|
|
209
|
+
or data.get("licensePlate")
|
|
210
|
+
or attrs.get("licensePlate")
|
|
211
|
+
or attrs.get("license_plate")
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Parse year from either top-level or attributes
|
|
216
|
+
year = data.get("year") or attrs.get("year")
|
|
217
|
+
if year is not None:
|
|
218
|
+
try:
|
|
219
|
+
vehicle.year = int(year)
|
|
220
|
+
except (ValueError, TypeError):
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
# Parse odometer from either top-level or attributes
|
|
224
|
+
odometer = data.get("odometer") or data.get("mileage") or attrs.get("odometer")
|
|
225
|
+
if odometer is not None:
|
|
226
|
+
try:
|
|
227
|
+
vehicle.odometer = float(odometer)
|
|
228
|
+
except (ValueError, TypeError):
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
ts = (
|
|
232
|
+
data.get("last_seen")
|
|
233
|
+
or data.get("lastSeen")
|
|
234
|
+
or data.get("last_updated")
|
|
235
|
+
or data.get("lastEventDateTime")
|
|
236
|
+
)
|
|
237
|
+
if ts:
|
|
238
|
+
vehicle.last_seen = _parse_timestamp(ts)
|
|
239
|
+
|
|
240
|
+
return vehicle
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_timestamp(ts: Any) -> datetime | None:
|
|
244
|
+
"""Parse various timestamp formats into datetime."""
|
|
245
|
+
if ts is None:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
if isinstance(ts, datetime):
|
|
249
|
+
return ts
|
|
250
|
+
|
|
251
|
+
if isinstance(ts, (int, float)):
|
|
252
|
+
# Unix timestamp (seconds or milliseconds)
|
|
253
|
+
if ts > 1e12: # Likely milliseconds
|
|
254
|
+
ts = ts / 1000
|
|
255
|
+
try:
|
|
256
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
257
|
+
except (ValueError, OSError):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
if isinstance(ts, str):
|
|
261
|
+
# Try ISO format first
|
|
262
|
+
for fmt in (
|
|
263
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
264
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
265
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
266
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
267
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
268
|
+
"%Y-%m-%d %H:%M:%S",
|
|
269
|
+
):
|
|
270
|
+
try:
|
|
271
|
+
dt = datetime.strptime(ts, fmt)
|
|
272
|
+
if dt.tzinfo is None:
|
|
273
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
274
|
+
return dt
|
|
275
|
+
except ValueError:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# Try fromisoformat as fallback
|
|
279
|
+
try:
|
|
280
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
281
|
+
return dt
|
|
282
|
+
except ValueError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
return None
|
lojack_api/py.typed
ADDED
|
File without changes
|
lojack_api/transport.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""HTTP transport layer using aiohttp.
|
|
2
|
+
|
|
3
|
+
This module handles all HTTP communication with the LoJack API.
|
|
4
|
+
It is designed to be compatible with Home Assistant's async patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import ssl
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
ApiError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
AuthorizationError,
|
|
19
|
+
ConnectionError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AiohttpTransport:
|
|
25
|
+
"""Async HTTP transport using aiohttp.
|
|
26
|
+
|
|
27
|
+
This class manages HTTP communication and can accept an external
|
|
28
|
+
aiohttp.ClientSession (recommended for Home Assistant) or create its own.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base_url: The base URL for API requests (e.g., "https://api.lojack.com").
|
|
32
|
+
session: Optional existing aiohttp.ClientSession to use.
|
|
33
|
+
timeout: Request timeout in seconds (default: 30).
|
|
34
|
+
ssl_context: Optional SSL context for custom certificate handling.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
base_url: str,
|
|
40
|
+
session: aiohttp.ClientSession | None = None,
|
|
41
|
+
timeout: float = 30.0,
|
|
42
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
self._external_session = session is not None
|
|
46
|
+
self._session = session
|
|
47
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
48
|
+
self._ssl_context = ssl_context
|
|
49
|
+
self._closed = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def closed(self) -> bool:
|
|
53
|
+
"""Return True if the transport has been closed."""
|
|
54
|
+
return self._closed
|
|
55
|
+
|
|
56
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
57
|
+
"""Get or create the aiohttp session."""
|
|
58
|
+
if self._closed:
|
|
59
|
+
raise ConnectionError("Transport has been closed")
|
|
60
|
+
|
|
61
|
+
if self._session is None or self._session.closed:
|
|
62
|
+
ssl_arg = self._ssl_context if self._ssl_context is not None else False
|
|
63
|
+
connector = aiohttp.TCPConnector(ssl=ssl_arg)
|
|
64
|
+
self._session = aiohttp.ClientSession(
|
|
65
|
+
timeout=self._timeout,
|
|
66
|
+
connector=connector,
|
|
67
|
+
)
|
|
68
|
+
return self._session
|
|
69
|
+
|
|
70
|
+
async def request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
*,
|
|
75
|
+
params: dict[str, Any] | None = None,
|
|
76
|
+
json: Any | None = None,
|
|
77
|
+
data: Any | None = None,
|
|
78
|
+
headers: dict[str, str] | None = None,
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""Make an HTTP request to the API.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.).
|
|
84
|
+
path: API path (will be joined with base_url).
|
|
85
|
+
params: Optional query parameters.
|
|
86
|
+
json: Optional JSON body (will be serialized).
|
|
87
|
+
data: Optional form data body.
|
|
88
|
+
headers: Optional additional headers.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Parsed JSON response if Content-Type is application/json,
|
|
92
|
+
otherwise the response text.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AuthenticationError: For 401 responses.
|
|
96
|
+
AuthorizationError: For 403 responses.
|
|
97
|
+
ApiError: For other HTTP errors.
|
|
98
|
+
ConnectionError: For network connectivity issues.
|
|
99
|
+
TimeoutError: For request timeouts.
|
|
100
|
+
"""
|
|
101
|
+
session = await self._get_session()
|
|
102
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
async with session.request(
|
|
106
|
+
method,
|
|
107
|
+
url,
|
|
108
|
+
params=params,
|
|
109
|
+
json=json,
|
|
110
|
+
data=data,
|
|
111
|
+
headers=headers,
|
|
112
|
+
ssl=self._ssl_context,
|
|
113
|
+
) as resp:
|
|
114
|
+
return await self._handle_response(resp)
|
|
115
|
+
|
|
116
|
+
except aiohttp.ClientResponseError as e:
|
|
117
|
+
raise self._map_http_error(e.status, str(e)) from e
|
|
118
|
+
except asyncio.TimeoutError as e:
|
|
119
|
+
raise TimeoutError(f"Request to {url} timed out") from e
|
|
120
|
+
except aiohttp.ClientConnectorError as e:
|
|
121
|
+
raise ConnectionError(f"Failed to connect to {url}: {e}") from e
|
|
122
|
+
except aiohttp.ClientError as e:
|
|
123
|
+
raise ConnectionError(f"Request failed: {e}") from e
|
|
124
|
+
|
|
125
|
+
async def _handle_response(self, resp: aiohttp.ClientResponse) -> Any:
|
|
126
|
+
"""Process the HTTP response."""
|
|
127
|
+
# Check for error status codes
|
|
128
|
+
if resp.status >= 400:
|
|
129
|
+
body = await self._safe_read_body(resp)
|
|
130
|
+
raise self._map_http_error(resp.status, body, body)
|
|
131
|
+
|
|
132
|
+
# Parse response based on content type
|
|
133
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
134
|
+
|
|
135
|
+
if "application/json" in content_type:
|
|
136
|
+
try:
|
|
137
|
+
return await resp.json()
|
|
138
|
+
except (aiohttp.ContentTypeError, ValueError):
|
|
139
|
+
# If JSON parsing fails, return text
|
|
140
|
+
text = await resp.text()
|
|
141
|
+
return text
|
|
142
|
+
|
|
143
|
+
return await resp.text()
|
|
144
|
+
|
|
145
|
+
async def _safe_read_body(self, resp: aiohttp.ClientResponse) -> str:
|
|
146
|
+
"""Safely read response body for error messages."""
|
|
147
|
+
try:
|
|
148
|
+
return await resp.text()
|
|
149
|
+
except Exception:
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
def _map_http_error(
|
|
153
|
+
self,
|
|
154
|
+
status: int,
|
|
155
|
+
message: str,
|
|
156
|
+
body: str | None = None,
|
|
157
|
+
) -> AuthenticationError | AuthorizationError | ApiError:
|
|
158
|
+
"""Map HTTP status codes to appropriate exceptions."""
|
|
159
|
+
if status == 401:
|
|
160
|
+
return AuthenticationError(message or "Authentication failed")
|
|
161
|
+
if status == 403:
|
|
162
|
+
return AuthorizationError(message or "Access denied")
|
|
163
|
+
return ApiError(message or f"HTTP {status}", status_code=status, response_body=body)
|
|
164
|
+
|
|
165
|
+
async def close(self) -> None:
|
|
166
|
+
"""Close the transport and release resources.
|
|
167
|
+
|
|
168
|
+
If the session was provided externally, it will NOT be closed
|
|
169
|
+
(the caller is responsible for managing it).
|
|
170
|
+
"""
|
|
171
|
+
if self._closed:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
self._closed = True
|
|
175
|
+
|
|
176
|
+
if self._session and not self._external_session:
|
|
177
|
+
if not self._session.closed:
|
|
178
|
+
await self._session.close()
|
|
179
|
+
|
|
180
|
+
self._session = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Keep backward compatibility alias
|
|
184
|
+
AiohttpClient = AiohttpTransport
|