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/_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)."""
@@ -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
+ ]