visor-python 0.1.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.
- visor/__init__.py +95 -0
- visor/_client.py +588 -0
- visor/_pagination.py +109 -0
- visor/_transport.py +130 -0
- visor/exceptions.py +72 -0
- visor/models/__init__.py +82 -0
- visor/models/_base.py +353 -0
- visor/models/dealers.py +81 -0
- visor/models/facets.py +142 -0
- visor/models/listings.py +205 -0
- visor/models/usage.py +30 -0
- visor/models/vins.py +9 -0
- visor/py.typed +0 -0
- visor_python-0.1.0.dist-info/METADATA +250 -0
- visor_python-0.1.0.dist-info/RECORD +17 -0
- visor_python-0.1.0.dist-info/WHEEL +4 -0
- visor_python-0.1.0.dist-info/licenses/LICENSE +21 -0
visor/_pagination.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Generator
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from visor.models.dealers import DealerFilter, DealersPage, DealerSummary
|
|
7
|
+
from visor.models.listings import ListingsFilter, ListingsPage, ListingSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _AsyncVisorClientProto(Protocol):
|
|
11
|
+
async def filter_listings(
|
|
12
|
+
self, filter: ListingsFilter | None = None
|
|
13
|
+
) -> ListingsPage: ...
|
|
14
|
+
|
|
15
|
+
async def search_dealers(
|
|
16
|
+
self, filter: DealerFilter | None = None
|
|
17
|
+
) -> DealersPage: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _SyncVisorClientProto(Protocol):
|
|
21
|
+
def filter_listings(self, filter: ListingsFilter | None = None) -> ListingsPage: ...
|
|
22
|
+
|
|
23
|
+
def search_dealers(self, filter: DealerFilter | None = None) -> DealersPage: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def paginate_listings(
|
|
27
|
+
client: _AsyncVisorClientProto,
|
|
28
|
+
filter: ListingsFilter | None = None,
|
|
29
|
+
*,
|
|
30
|
+
max_pages: int | None = None,
|
|
31
|
+
) -> AsyncGenerator[ListingSummary, None]:
|
|
32
|
+
if max_pages is not None and max_pages <= 0:
|
|
33
|
+
return
|
|
34
|
+
current = filter if filter is not None else ListingsFilter()
|
|
35
|
+
pages_fetched = 0
|
|
36
|
+
while True:
|
|
37
|
+
page = await client.filter_listings(current)
|
|
38
|
+
for item in page.data:
|
|
39
|
+
yield item
|
|
40
|
+
pages_fetched += 1
|
|
41
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
42
|
+
break
|
|
43
|
+
if page.pagination.next_offset is None:
|
|
44
|
+
break
|
|
45
|
+
current = current.model_copy(update={"offset": page.pagination.next_offset})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def paginate_dealers(
|
|
49
|
+
client: _AsyncVisorClientProto,
|
|
50
|
+
filter: DealerFilter | None = None,
|
|
51
|
+
*,
|
|
52
|
+
max_pages: int | None = None,
|
|
53
|
+
) -> AsyncGenerator[DealerSummary, None]:
|
|
54
|
+
if max_pages is not None and max_pages <= 0:
|
|
55
|
+
return
|
|
56
|
+
current = filter if filter is not None else DealerFilter()
|
|
57
|
+
pages_fetched = 0
|
|
58
|
+
while True:
|
|
59
|
+
page = await client.search_dealers(current)
|
|
60
|
+
for item in page.data:
|
|
61
|
+
yield item
|
|
62
|
+
pages_fetched += 1
|
|
63
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
64
|
+
break
|
|
65
|
+
if page.pagination.next_offset is None:
|
|
66
|
+
break
|
|
67
|
+
current = current.model_copy(update={"offset": page.pagination.next_offset})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def iter_listings(
|
|
71
|
+
client: _SyncVisorClientProto,
|
|
72
|
+
filter: ListingsFilter | None = None,
|
|
73
|
+
*,
|
|
74
|
+
max_pages: int | None = None,
|
|
75
|
+
) -> Generator[ListingSummary, None, None]:
|
|
76
|
+
if max_pages is not None and max_pages <= 0:
|
|
77
|
+
return
|
|
78
|
+
current = filter if filter is not None else ListingsFilter()
|
|
79
|
+
pages_fetched = 0
|
|
80
|
+
while True:
|
|
81
|
+
page = client.filter_listings(current)
|
|
82
|
+
yield from page.data
|
|
83
|
+
pages_fetched += 1
|
|
84
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
85
|
+
break
|
|
86
|
+
if page.pagination.next_offset is None:
|
|
87
|
+
break
|
|
88
|
+
current = current.model_copy(update={"offset": page.pagination.next_offset})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def iter_dealers(
|
|
92
|
+
client: _SyncVisorClientProto,
|
|
93
|
+
filter: DealerFilter | None = None,
|
|
94
|
+
*,
|
|
95
|
+
max_pages: int | None = None,
|
|
96
|
+
) -> Generator[DealerSummary, None, None]:
|
|
97
|
+
if max_pages is not None and max_pages <= 0:
|
|
98
|
+
return
|
|
99
|
+
current = filter if filter is not None else DealerFilter()
|
|
100
|
+
pages_fetched = 0
|
|
101
|
+
while True:
|
|
102
|
+
page = client.search_dealers(current)
|
|
103
|
+
yield from page.data
|
|
104
|
+
pages_fetched += 1
|
|
105
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
106
|
+
break
|
|
107
|
+
if page.pagination.next_offset is None:
|
|
108
|
+
break
|
|
109
|
+
current = current.model_copy(update={"offset": page.pagination.next_offset})
|
visor/_transport.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from email.utils import parsedate_to_datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from visor.exceptions import (
|
|
8
|
+
AuthError,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PaymentRequiredError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
VisorAPIError,
|
|
15
|
+
VisorTransportError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.visor.vin/v1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_retry_after(value: str | None) -> int | None:
|
|
22
|
+
if not value:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return int(value)
|
|
26
|
+
except ValueError:
|
|
27
|
+
try:
|
|
28
|
+
retry_at = parsedate_to_datetime(value)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return None
|
|
31
|
+
return max(0, int((retry_at - datetime.now(retry_at.tzinfo)).total_seconds()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
|
35
|
+
if response.is_success:
|
|
36
|
+
try:
|
|
37
|
+
data: Any = response.json()
|
|
38
|
+
except ValueError as e:
|
|
39
|
+
raise VisorTransportError(f"Received malformed JSON from API: {e}") from e
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
raise VisorTransportError("Received non-object JSON from API")
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
body = response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
body = None
|
|
48
|
+
|
|
49
|
+
error: object = body.get("error") if isinstance(body, dict) else None
|
|
50
|
+
if isinstance(error, dict):
|
|
51
|
+
error_code = error.get("code", "unknown_error") or "unknown_error"
|
|
52
|
+
message = error.get("message", response.text) or response.text
|
|
53
|
+
else:
|
|
54
|
+
error_code = "unknown_error"
|
|
55
|
+
message = response.text
|
|
56
|
+
|
|
57
|
+
match response.status_code:
|
|
58
|
+
case 400:
|
|
59
|
+
raise ValidationError(400, error_code, message)
|
|
60
|
+
case 401:
|
|
61
|
+
raise AuthError(
|
|
62
|
+
401,
|
|
63
|
+
error_code,
|
|
64
|
+
message or "Invalid or missing API key. Check VISOR_API_KEY.",
|
|
65
|
+
)
|
|
66
|
+
case 402:
|
|
67
|
+
raise PaymentRequiredError(402, error_code, message)
|
|
68
|
+
case 403:
|
|
69
|
+
raise ForbiddenError(
|
|
70
|
+
403,
|
|
71
|
+
error_code,
|
|
72
|
+
message or "Access denied. Key lacks permission for this resource.",
|
|
73
|
+
)
|
|
74
|
+
case 404:
|
|
75
|
+
raise NotFoundError(404, error_code, message)
|
|
76
|
+
case 429:
|
|
77
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
78
|
+
raise RateLimitError(429, error_code, message, retry_after=retry_after)
|
|
79
|
+
case _:
|
|
80
|
+
raise VisorAPIError(response.status_code, error_code, message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AsyncVisorTransport:
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
api_key: str,
|
|
87
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
88
|
+
timeout: float = 30.0,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._client = httpx.AsyncClient(
|
|
91
|
+
base_url=base_url,
|
|
92
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def get(
|
|
97
|
+
self, path: str, params: dict[str, str] | None = None
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
try:
|
|
100
|
+
response = await self._client.get(path, params=params or {})
|
|
101
|
+
except httpx.RequestError as e:
|
|
102
|
+
raise VisorTransportError(f"Request failed: {e}") from e
|
|
103
|
+
return _handle_response(response)
|
|
104
|
+
|
|
105
|
+
async def aclose(self) -> None:
|
|
106
|
+
await self._client.aclose()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SyncVisorTransport:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: str,
|
|
113
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
114
|
+
timeout: float = 30.0,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._client = httpx.Client(
|
|
117
|
+
base_url=base_url,
|
|
118
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]:
|
|
123
|
+
try:
|
|
124
|
+
response = self._client.get(path, params=params or {})
|
|
125
|
+
except httpx.RequestError as e:
|
|
126
|
+
raise VisorTransportError(f"Request failed: {e}") from e
|
|
127
|
+
return _handle_response(response)
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
self._client.close()
|
visor/exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
class VisorError(Exception):
|
|
2
|
+
"""Base exception for all visor-python errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class VisorAPIError(VisorError):
|
|
6
|
+
"""Raised when the API returns a 4xx or 5xx response."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, status_code: int, error_code: str, message: str) -> None:
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
self.message = message
|
|
12
|
+
super().__init__(f"[{status_code}] {error_code}: {message}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthError(VisorAPIError):
|
|
16
|
+
"""HTTP 401 — the request was not authenticated.
|
|
17
|
+
|
|
18
|
+
Raised when the API key is missing, malformed, or invalid. Check that
|
|
19
|
+
``VISOR_API_KEY`` is set to a valid key, or pass ``api_key`` explicitly
|
|
20
|
+
when constructing the client.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ForbiddenError(VisorAPIError):
|
|
25
|
+
"""HTTP 403 — the request was authenticated but not authorized.
|
|
26
|
+
|
|
27
|
+
Raised when a valid API key does not have permission to access the
|
|
28
|
+
requested resource or perform the requested action. Contact Visor support
|
|
29
|
+
if you believe you should have access.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFoundError(VisorAPIError):
|
|
34
|
+
"""404 — resource not found."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(VisorAPIError):
|
|
38
|
+
"""400 — unknown_query_parameter or validation_error."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PaymentRequiredError(VisorAPIError):
|
|
42
|
+
"""402 — quota exceeded / payment required."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitError(VisorAPIError):
|
|
46
|
+
"""HTTP 429 — the API rate limit has been exceeded.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
retry_after: Number of seconds to wait before retrying, or ``None``
|
|
50
|
+
if the API did not include a usable ``Retry-After`` header. When
|
|
51
|
+
present the value is derived from an integer seconds value or an
|
|
52
|
+
HTTP-date header, whichever the API provides. Build your retry
|
|
53
|
+
loop around this value:
|
|
54
|
+
|
|
55
|
+
except RateLimitError as e:
|
|
56
|
+
wait = e.retry_after if e.retry_after is not None else 2 ** attempt
|
|
57
|
+
time.sleep(wait)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
status_code: int,
|
|
63
|
+
error_code: str,
|
|
64
|
+
message: str,
|
|
65
|
+
retry_after: int | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.retry_after = retry_after
|
|
68
|
+
super().__init__(status_code, error_code, message)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class VisorTransportError(VisorError):
|
|
72
|
+
"""Raised on network-level failures (timeout, connection refused)."""
|
visor/models/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from visor.models._base import (
|
|
2
|
+
BBox,
|
|
3
|
+
DealerRef,
|
|
4
|
+
InventoryMode,
|
|
5
|
+
Pagination,
|
|
6
|
+
PriceHistoryEntry,
|
|
7
|
+
SortOrder,
|
|
8
|
+
VehicleBuild,
|
|
9
|
+
VehicleOption,
|
|
10
|
+
VehicleRecord,
|
|
11
|
+
VisorRequestModel,
|
|
12
|
+
VisorResponseModel,
|
|
13
|
+
)
|
|
14
|
+
from visor.models.dealers import (
|
|
15
|
+
DealerAddress,
|
|
16
|
+
DealerDetail,
|
|
17
|
+
DealerFilter,
|
|
18
|
+
DealersPage,
|
|
19
|
+
DealerSummary,
|
|
20
|
+
)
|
|
21
|
+
from visor.models.facets import (
|
|
22
|
+
FacetBucket,
|
|
23
|
+
FacetsData,
|
|
24
|
+
FacetsFilter,
|
|
25
|
+
FacetsMeta,
|
|
26
|
+
FacetsResponse,
|
|
27
|
+
FieldStats,
|
|
28
|
+
RangeBucket,
|
|
29
|
+
RangeFacet,
|
|
30
|
+
)
|
|
31
|
+
from visor.models.listings import (
|
|
32
|
+
ListingDetail,
|
|
33
|
+
ListingsFilter,
|
|
34
|
+
ListingSnapshot,
|
|
35
|
+
ListingsPage,
|
|
36
|
+
ListingSummary,
|
|
37
|
+
)
|
|
38
|
+
from visor.models.usage import UsageMeta, UsageRecord, UsageSummary, UsageTotals
|
|
39
|
+
from visor.models.vins import VinDetail
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# base
|
|
43
|
+
"BBox",
|
|
44
|
+
"DealerRef",
|
|
45
|
+
"InventoryMode",
|
|
46
|
+
"Pagination",
|
|
47
|
+
"PriceHistoryEntry",
|
|
48
|
+
"SortOrder",
|
|
49
|
+
"VehicleBuild",
|
|
50
|
+
"VehicleOption",
|
|
51
|
+
"VehicleRecord",
|
|
52
|
+
"VisorRequestModel",
|
|
53
|
+
"VisorResponseModel",
|
|
54
|
+
# dealers
|
|
55
|
+
"DealerAddress",
|
|
56
|
+
"DealerDetail",
|
|
57
|
+
"DealerFilter",
|
|
58
|
+
"DealersPage",
|
|
59
|
+
"DealerSummary",
|
|
60
|
+
# facets
|
|
61
|
+
"FacetBucket",
|
|
62
|
+
"FacetsData",
|
|
63
|
+
"FacetsMeta",
|
|
64
|
+
"FacetsFilter",
|
|
65
|
+
"FacetsResponse",
|
|
66
|
+
"FieldStats",
|
|
67
|
+
"RangeBucket",
|
|
68
|
+
"RangeFacet",
|
|
69
|
+
# listings
|
|
70
|
+
"ListingDetail",
|
|
71
|
+
"ListingsFilter",
|
|
72
|
+
"ListingsPage",
|
|
73
|
+
"ListingSnapshot",
|
|
74
|
+
"ListingSummary",
|
|
75
|
+
# usage
|
|
76
|
+
"UsageMeta",
|
|
77
|
+
"UsageRecord",
|
|
78
|
+
"UsageSummary",
|
|
79
|
+
"UsageTotals",
|
|
80
|
+
# vins
|
|
81
|
+
"VinDetail",
|
|
82
|
+
]
|