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/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from visor._client import AsyncVisorClient, VisorClient
|
|
2
|
+
from visor._pagination import (
|
|
3
|
+
iter_dealers,
|
|
4
|
+
iter_listings,
|
|
5
|
+
paginate_dealers,
|
|
6
|
+
paginate_listings,
|
|
7
|
+
)
|
|
8
|
+
from visor.exceptions import (
|
|
9
|
+
AuthError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PaymentRequiredError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
VisorAPIError,
|
|
16
|
+
VisorError,
|
|
17
|
+
VisorTransportError,
|
|
18
|
+
)
|
|
19
|
+
from visor.models._base import (
|
|
20
|
+
BBox,
|
|
21
|
+
DealerRef,
|
|
22
|
+
Pagination,
|
|
23
|
+
VehicleBuild,
|
|
24
|
+
VehicleOption,
|
|
25
|
+
VehicleRecord,
|
|
26
|
+
)
|
|
27
|
+
from visor.models.dealers import (
|
|
28
|
+
DealerAddress,
|
|
29
|
+
DealerDetail,
|
|
30
|
+
DealerFilter,
|
|
31
|
+
DealersPage,
|
|
32
|
+
DealerSummary,
|
|
33
|
+
)
|
|
34
|
+
from visor.models.facets import FacetBucket, FacetsData, FacetsFilter, FacetsResponse
|
|
35
|
+
from visor.models.listings import (
|
|
36
|
+
ListingDetail,
|
|
37
|
+
ListingsFilter,
|
|
38
|
+
ListingSnapshot,
|
|
39
|
+
ListingsPage,
|
|
40
|
+
ListingSummary,
|
|
41
|
+
)
|
|
42
|
+
from visor.models.usage import UsageMeta, UsageRecord, UsageSummary, UsageTotals
|
|
43
|
+
from visor.models.vins import VinDetail
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# client
|
|
47
|
+
"AsyncVisorClient",
|
|
48
|
+
"VisorClient",
|
|
49
|
+
# pagination
|
|
50
|
+
"paginate_listings",
|
|
51
|
+
"paginate_dealers",
|
|
52
|
+
"iter_listings",
|
|
53
|
+
"iter_dealers",
|
|
54
|
+
# exceptions
|
|
55
|
+
"AuthError",
|
|
56
|
+
"ForbiddenError",
|
|
57
|
+
"NotFoundError",
|
|
58
|
+
"PaymentRequiredError",
|
|
59
|
+
"RateLimitError",
|
|
60
|
+
"ValidationError",
|
|
61
|
+
"VisorAPIError",
|
|
62
|
+
"VisorError",
|
|
63
|
+
"VisorTransportError",
|
|
64
|
+
# shared base
|
|
65
|
+
"BBox",
|
|
66
|
+
"DealerRef",
|
|
67
|
+
"Pagination",
|
|
68
|
+
"VehicleBuild",
|
|
69
|
+
"VehicleOption",
|
|
70
|
+
"VehicleRecord",
|
|
71
|
+
# dealers
|
|
72
|
+
"DealerAddress",
|
|
73
|
+
"DealerDetail",
|
|
74
|
+
"DealerFilter",
|
|
75
|
+
"DealersPage",
|
|
76
|
+
"DealerSummary",
|
|
77
|
+
# facets
|
|
78
|
+
"FacetBucket",
|
|
79
|
+
"FacetsData",
|
|
80
|
+
"FacetsFilter",
|
|
81
|
+
"FacetsResponse",
|
|
82
|
+
# listings
|
|
83
|
+
"ListingDetail",
|
|
84
|
+
"ListingsFilter",
|
|
85
|
+
"ListingsPage",
|
|
86
|
+
"ListingSnapshot",
|
|
87
|
+
"ListingSummary",
|
|
88
|
+
# usage
|
|
89
|
+
"UsageMeta",
|
|
90
|
+
"UsageRecord",
|
|
91
|
+
"UsageSummary",
|
|
92
|
+
"UsageTotals",
|
|
93
|
+
# vins
|
|
94
|
+
"VinDetail",
|
|
95
|
+
]
|
visor/_client.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from visor._transport import DEFAULT_BASE_URL, AsyncVisorTransport, SyncVisorTransport
|
|
6
|
+
from visor.models.dealers import DealerDetail, DealerFilter, DealersPage
|
|
7
|
+
from visor.models.facets import FacetsFilter, FacetsResponse
|
|
8
|
+
from visor.models.listings import ListingDetail, ListingsFilter, ListingsPage
|
|
9
|
+
from visor.models.usage import UsageSummary
|
|
10
|
+
from visor.models.vins import VinDetail
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncVisorClient:
|
|
14
|
+
"""Async client for the Visor Public API.
|
|
15
|
+
|
|
16
|
+
All methods are coroutines and must be awaited. Use as an async context
|
|
17
|
+
manager to ensure the underlying HTTP connection pool is closed:
|
|
18
|
+
|
|
19
|
+
async with AsyncVisorClient() as client:
|
|
20
|
+
page = await client.filter_listings(...)
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: Visor API key. Defaults to the ``VISOR_API_KEY`` environment
|
|
24
|
+
variable.
|
|
25
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
26
|
+
base_url: API base URL. Override for local testing or staging.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If no API key is provided or found in the environment.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
timeout: float = 30.0,
|
|
36
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
37
|
+
) -> None:
|
|
38
|
+
key = api_key or os.environ.get("VISOR_API_KEY")
|
|
39
|
+
if not key:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"api_key is required. Pass it directly or set VISOR_API_KEY."
|
|
42
|
+
)
|
|
43
|
+
self._transport = AsyncVisorTransport(key, base_url=base_url, timeout=timeout)
|
|
44
|
+
|
|
45
|
+
async def __aenter__(self) -> "AsyncVisorClient":
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(self, *args: object) -> None:
|
|
49
|
+
await self.aclose()
|
|
50
|
+
|
|
51
|
+
async def aclose(self) -> None:
|
|
52
|
+
"""Close the underlying HTTP client and release connections."""
|
|
53
|
+
await self._transport.aclose()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ #
|
|
56
|
+
# Inventory #
|
|
57
|
+
# ------------------------------------------------------------------ #
|
|
58
|
+
|
|
59
|
+
async def filter_listings(
|
|
60
|
+
self, filter: ListingsFilter | None = None
|
|
61
|
+
) -> ListingsPage:
|
|
62
|
+
"""Return a single page of listings matching the given filter.
|
|
63
|
+
|
|
64
|
+
To iterate all results across pages, use :func:`visor.paginate_listings`
|
|
65
|
+
instead.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
filter: Search and pagination criteria. Defaults to an empty filter
|
|
69
|
+
(first page, no constraints).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A :class:`~visor.models.listings.ListingsPage` containing the
|
|
73
|
+
matched listings and pagination metadata.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
77
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
78
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
79
|
+
VisorAPIError: Any other API-level error.
|
|
80
|
+
VisorTransportError: Network-level failure (timeout, connection).
|
|
81
|
+
"""
|
|
82
|
+
params = (filter or ListingsFilter()).to_params()
|
|
83
|
+
data = await self._transport.get("/listings", params)
|
|
84
|
+
return ListingsPage.model_validate(data)
|
|
85
|
+
|
|
86
|
+
async def get_listing(
|
|
87
|
+
self,
|
|
88
|
+
listing_id: str,
|
|
89
|
+
include: list[Literal["price_history", "options"]] | None = None,
|
|
90
|
+
) -> ListingDetail:
|
|
91
|
+
"""Return full detail for a single listing by its ID.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
listing_id: The unique listing identifier.
|
|
95
|
+
include: Optional extra sections to embed in the response.
|
|
96
|
+
``"price_history"`` adds historical price records;
|
|
97
|
+
``"options"`` adds option/package details.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A :class:`~visor.models.listings.ListingDetail` for the requested
|
|
101
|
+
listing.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
NotFoundError: No listing with that ID exists (HTTP 404).
|
|
105
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
106
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
107
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
108
|
+
VisorAPIError: Any other API-level error.
|
|
109
|
+
VisorTransportError: Network-level failure.
|
|
110
|
+
"""
|
|
111
|
+
params: dict[str, str] = {}
|
|
112
|
+
if include:
|
|
113
|
+
params["include"] = ",".join(include)
|
|
114
|
+
data = await self._transport.get(f"/listings/{listing_id}", params)
|
|
115
|
+
return ListingDetail.model_validate(data["data"])
|
|
116
|
+
|
|
117
|
+
async def lookup_vin(
|
|
118
|
+
self,
|
|
119
|
+
vin: str,
|
|
120
|
+
include: list[Literal["price_history", "options"]] | None = None,
|
|
121
|
+
) -> VinDetail:
|
|
122
|
+
"""Return build and listing information for a VIN.
|
|
123
|
+
|
|
124
|
+
The returned :class:`~visor.models.vins.VinDetail` always includes
|
|
125
|
+
``vin``, ``status``, and ``build``. The ``latest_listing`` field is
|
|
126
|
+
``None`` when no active or recent listing exists for the VIN.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
vin: 17-character Vehicle Identification Number.
|
|
130
|
+
include: Optional extra sections to embed. ``"price_history"``
|
|
131
|
+
adds historical price records; ``"options"`` adds option
|
|
132
|
+
details on the latest listing.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A :class:`~visor.models.vins.VinDetail` for the requested VIN.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
NotFoundError: VIN not found in the Visor database (HTTP 404).
|
|
139
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
140
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
141
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
142
|
+
VisorAPIError: Any other API-level error.
|
|
143
|
+
VisorTransportError: Network-level failure.
|
|
144
|
+
"""
|
|
145
|
+
params: dict[str, str] = {}
|
|
146
|
+
if include:
|
|
147
|
+
params["include"] = ",".join(include)
|
|
148
|
+
data = await self._transport.get(f"/vins/{vin}", params)
|
|
149
|
+
return VinDetail.model_validate(data["data"])
|
|
150
|
+
|
|
151
|
+
async def filter_facets(self, filter: FacetsFilter) -> FacetsResponse:
|
|
152
|
+
"""Return facet aggregations for the given filter.
|
|
153
|
+
|
|
154
|
+
Facets summarize available field values and ranges across all listings
|
|
155
|
+
matching the filter, useful for building search-UI refinement panels.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
filter: Facet query criteria, including which facets to compute.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
A :class:`~visor.models.facets.FacetsResponse` with aggregation
|
|
162
|
+
data for each requested facet.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
166
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
167
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
168
|
+
VisorAPIError: Any other API-level error.
|
|
169
|
+
VisorTransportError: Network-level failure.
|
|
170
|
+
"""
|
|
171
|
+
data = await self._transport.get("/facets", filter.to_params())
|
|
172
|
+
return FacetsResponse.model_validate(data)
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
# Dealers #
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
|
|
178
|
+
async def search_dealers(self, filter: DealerFilter | None = None) -> DealersPage:
|
|
179
|
+
"""Return a single page of dealers matching the given filter.
|
|
180
|
+
|
|
181
|
+
To iterate all dealers across pages, use :func:`visor.paginate_dealers`
|
|
182
|
+
instead.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
filter: Search criteria and pagination parameters. Defaults to an
|
|
186
|
+
empty filter (first page, no constraints).
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A :class:`~visor.models.dealers.DealersPage` containing matched
|
|
190
|
+
dealers and pagination metadata.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
194
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
195
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
196
|
+
VisorAPIError: Any other API-level error.
|
|
197
|
+
VisorTransportError: Network-level failure.
|
|
198
|
+
"""
|
|
199
|
+
params = (filter or DealerFilter()).to_params()
|
|
200
|
+
data = await self._transport.get("/dealers", params)
|
|
201
|
+
return DealersPage.model_validate(data)
|
|
202
|
+
|
|
203
|
+
async def get_dealer(self, dealer_id: str) -> DealerDetail:
|
|
204
|
+
"""Return full detail for a single dealer by its ID.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
dealer_id: The unique dealer identifier.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A :class:`~visor.models.dealers.DealerDetail` for the requested
|
|
211
|
+
dealer.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
NotFoundError: No dealer with that ID exists (HTTP 404).
|
|
215
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
216
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
217
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
218
|
+
VisorAPIError: Any other API-level error.
|
|
219
|
+
VisorTransportError: Network-level failure.
|
|
220
|
+
"""
|
|
221
|
+
data = await self._transport.get(f"/dealers/{dealer_id}")
|
|
222
|
+
return DealerDetail.model_validate(data["data"])
|
|
223
|
+
|
|
224
|
+
async def dealer_inventory(
|
|
225
|
+
self,
|
|
226
|
+
dealer_id: str,
|
|
227
|
+
filter: ListingsFilter | None = None,
|
|
228
|
+
) -> ListingsPage:
|
|
229
|
+
"""Return a single page of inventory for a specific dealer.
|
|
230
|
+
|
|
231
|
+
Accepts the same :class:`~visor.models.listings.ListingsFilter` shape
|
|
232
|
+
as :meth:`filter_listings`, so you can reuse a filter object across
|
|
233
|
+
both methods. Call this method in a loop advancing ``filter.offset`` to
|
|
234
|
+
paginate through all inventory pages.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
dealer_id: The unique dealer identifier.
|
|
238
|
+
filter: Optional search and pagination criteria.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
A :class:`~visor.models.listings.ListingsPage` of the dealer's
|
|
242
|
+
inventory.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
NotFoundError: No dealer with that ID exists (HTTP 404).
|
|
246
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
247
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
248
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
249
|
+
VisorAPIError: Any other API-level error.
|
|
250
|
+
VisorTransportError: Network-level failure.
|
|
251
|
+
"""
|
|
252
|
+
params = (filter or ListingsFilter()).to_params()
|
|
253
|
+
data = await self._transport.get(f"/dealers/{dealer_id}/listings", params)
|
|
254
|
+
return ListingsPage.model_validate(data)
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------ #
|
|
257
|
+
# Usage #
|
|
258
|
+
# ------------------------------------------------------------------ #
|
|
259
|
+
|
|
260
|
+
async def get_usage(
|
|
261
|
+
self,
|
|
262
|
+
start_date: date | None = None,
|
|
263
|
+
end_date: date | None = None,
|
|
264
|
+
metering_class: list[str] | None = None,
|
|
265
|
+
) -> UsageSummary:
|
|
266
|
+
"""Return API usage statistics for your account.
|
|
267
|
+
|
|
268
|
+
All parameters are optional. Omitting date bounds returns the full
|
|
269
|
+
available history. Omitting ``metering_class`` returns all endpoint
|
|
270
|
+
categories.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
start_date: Start of the reporting window (inclusive).
|
|
274
|
+
end_date: End of the reporting window (inclusive).
|
|
275
|
+
metering_class: Endpoint categories to include, e.g.
|
|
276
|
+
``["listings", "dealers"]``. Returns all categories when
|
|
277
|
+
omitted.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
A :class:`~visor.models.usage.UsageSummary` with request counts
|
|
281
|
+
and quota details.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
285
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
286
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
287
|
+
VisorAPIError: Any other API-level error.
|
|
288
|
+
VisorTransportError: Network-level failure.
|
|
289
|
+
"""
|
|
290
|
+
params: dict[str, str] = {}
|
|
291
|
+
if start_date:
|
|
292
|
+
params["start_date"] = start_date.isoformat()
|
|
293
|
+
if end_date:
|
|
294
|
+
params["end_date"] = end_date.isoformat()
|
|
295
|
+
if metering_class:
|
|
296
|
+
params["metering_class"] = ",".join(metering_class)
|
|
297
|
+
data = await self._transport.get("/usage", params)
|
|
298
|
+
return UsageSummary.model_validate(data)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class VisorClient:
|
|
302
|
+
"""Synchronous client for the Visor Public API.
|
|
303
|
+
|
|
304
|
+
All methods block until the HTTP response is received. The client owns an
|
|
305
|
+
internal :class:`httpx.Client` with a connection pool; it is **not**
|
|
306
|
+
thread-safe. If you share a single ``VisorClient`` across threads, you must
|
|
307
|
+
add external synchronization — or create one client per thread instead.
|
|
308
|
+
|
|
309
|
+
Use as a context manager to ensure the connection pool is released:
|
|
310
|
+
|
|
311
|
+
with VisorClient() as client:
|
|
312
|
+
page = client.filter_listings(...)
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
api_key: Visor API key. Defaults to the ``VISOR_API_KEY`` environment
|
|
316
|
+
variable.
|
|
317
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
318
|
+
base_url: API base URL. Override for local testing or staging.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
ValueError: If no API key is provided or found in the environment.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
def __init__(
|
|
325
|
+
self,
|
|
326
|
+
api_key: str | None = None,
|
|
327
|
+
timeout: float = 30.0,
|
|
328
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
329
|
+
) -> None:
|
|
330
|
+
key = api_key or os.environ.get("VISOR_API_KEY")
|
|
331
|
+
if not key:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
"api_key is required. Pass it directly or set VISOR_API_KEY."
|
|
334
|
+
)
|
|
335
|
+
self._transport = SyncVisorTransport(key, base_url=base_url, timeout=timeout)
|
|
336
|
+
|
|
337
|
+
def __enter__(self) -> "VisorClient":
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
def __exit__(self, *args: object) -> None:
|
|
341
|
+
self.close()
|
|
342
|
+
|
|
343
|
+
def close(self) -> None:
|
|
344
|
+
"""Close the underlying HTTP client and release connections."""
|
|
345
|
+
self._transport.close()
|
|
346
|
+
|
|
347
|
+
# ------------------------------------------------------------------ #
|
|
348
|
+
# Inventory #
|
|
349
|
+
# ------------------------------------------------------------------ #
|
|
350
|
+
|
|
351
|
+
def filter_listings(self, filter: ListingsFilter | None = None) -> ListingsPage:
|
|
352
|
+
"""Return a single page of listings matching the given filter.
|
|
353
|
+
|
|
354
|
+
To iterate all results across pages, use :func:`visor.iter_listings`
|
|
355
|
+
instead.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
filter: Search and pagination criteria. Defaults to an empty filter
|
|
359
|
+
(first page, no constraints).
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A :class:`~visor.models.listings.ListingsPage` containing the
|
|
363
|
+
matched listings and pagination metadata.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
367
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
368
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
369
|
+
VisorAPIError: Any other API-level error.
|
|
370
|
+
VisorTransportError: Network-level failure (timeout, connection).
|
|
371
|
+
"""
|
|
372
|
+
params = (filter or ListingsFilter()).to_params()
|
|
373
|
+
data = self._transport.get("/listings", params)
|
|
374
|
+
return ListingsPage.model_validate(data)
|
|
375
|
+
|
|
376
|
+
def get_listing(
|
|
377
|
+
self,
|
|
378
|
+
listing_id: str,
|
|
379
|
+
include: list[Literal["price_history", "options"]] | None = None,
|
|
380
|
+
) -> ListingDetail:
|
|
381
|
+
"""Return full detail for a single listing by its ID.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
listing_id: The unique listing identifier.
|
|
385
|
+
include: Optional extra sections to embed in the response.
|
|
386
|
+
``"price_history"`` adds historical price records;
|
|
387
|
+
``"options"`` adds option/package details.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
A :class:`~visor.models.listings.ListingDetail` for the requested
|
|
391
|
+
listing.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
NotFoundError: No listing with that ID exists (HTTP 404).
|
|
395
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
396
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
397
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
398
|
+
VisorAPIError: Any other API-level error.
|
|
399
|
+
VisorTransportError: Network-level failure.
|
|
400
|
+
"""
|
|
401
|
+
params: dict[str, str] = {}
|
|
402
|
+
if include:
|
|
403
|
+
params["include"] = ",".join(include)
|
|
404
|
+
data = self._transport.get(f"/listings/{listing_id}", params)
|
|
405
|
+
return ListingDetail.model_validate(data["data"])
|
|
406
|
+
|
|
407
|
+
def lookup_vin(
|
|
408
|
+
self,
|
|
409
|
+
vin: str,
|
|
410
|
+
include: list[Literal["price_history", "options"]] | None = None,
|
|
411
|
+
) -> VinDetail:
|
|
412
|
+
"""Return build and listing information for a VIN.
|
|
413
|
+
|
|
414
|
+
The returned :class:`~visor.models.vins.VinDetail` always includes
|
|
415
|
+
``vin``, ``status``, and ``build``. The ``latest_listing`` field is
|
|
416
|
+
``None`` when no active or recent listing exists for the VIN.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
vin: 17-character Vehicle Identification Number.
|
|
420
|
+
include: Optional extra sections to embed. ``"price_history"``
|
|
421
|
+
adds historical price records; ``"options"`` adds option
|
|
422
|
+
details on the latest listing.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
A :class:`~visor.models.vins.VinDetail` for the requested VIN.
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
NotFoundError: VIN not found in the Visor database (HTTP 404).
|
|
429
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
430
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
431
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
432
|
+
VisorAPIError: Any other API-level error.
|
|
433
|
+
VisorTransportError: Network-level failure.
|
|
434
|
+
"""
|
|
435
|
+
params: dict[str, str] = {}
|
|
436
|
+
if include:
|
|
437
|
+
params["include"] = ",".join(include)
|
|
438
|
+
data = self._transport.get(f"/vins/{vin}", params)
|
|
439
|
+
return VinDetail.model_validate(data["data"])
|
|
440
|
+
|
|
441
|
+
def filter_facets(self, filter: FacetsFilter) -> FacetsResponse:
|
|
442
|
+
"""Return facet aggregations for the given filter.
|
|
443
|
+
|
|
444
|
+
Facets summarize available field values and ranges across all listings
|
|
445
|
+
matching the filter, useful for building search-UI refinement panels.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
filter: Facet query criteria, including which facets to compute.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
A :class:`~visor.models.facets.FacetsResponse` with aggregation
|
|
452
|
+
data for each requested facet.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
456
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
457
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
458
|
+
VisorAPIError: Any other API-level error.
|
|
459
|
+
VisorTransportError: Network-level failure.
|
|
460
|
+
"""
|
|
461
|
+
data = self._transport.get("/facets", filter.to_params())
|
|
462
|
+
return FacetsResponse.model_validate(data)
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------ #
|
|
465
|
+
# Dealers #
|
|
466
|
+
# ------------------------------------------------------------------ #
|
|
467
|
+
|
|
468
|
+
def search_dealers(self, filter: DealerFilter | None = None) -> DealersPage:
|
|
469
|
+
"""Return a single page of dealers matching the given filter.
|
|
470
|
+
|
|
471
|
+
To iterate all dealers across pages, use :func:`visor.iter_dealers`
|
|
472
|
+
instead.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
filter: Search criteria and pagination parameters. Defaults to an
|
|
476
|
+
empty filter (first page, no constraints).
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
A :class:`~visor.models.dealers.DealersPage` containing matched
|
|
480
|
+
dealers and pagination metadata.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
484
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
485
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
486
|
+
VisorAPIError: Any other API-level error.
|
|
487
|
+
VisorTransportError: Network-level failure.
|
|
488
|
+
"""
|
|
489
|
+
params = (filter or DealerFilter()).to_params()
|
|
490
|
+
data = self._transport.get("/dealers", params)
|
|
491
|
+
return DealersPage.model_validate(data)
|
|
492
|
+
|
|
493
|
+
def get_dealer(self, dealer_id: str) -> DealerDetail:
|
|
494
|
+
"""Return full detail for a single dealer by its ID.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
dealer_id: The unique dealer identifier.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
A :class:`~visor.models.dealers.DealerDetail` for the requested
|
|
501
|
+
dealer.
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
NotFoundError: No dealer with that ID exists (HTTP 404).
|
|
505
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
506
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
507
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
508
|
+
VisorAPIError: Any other API-level error.
|
|
509
|
+
VisorTransportError: Network-level failure.
|
|
510
|
+
"""
|
|
511
|
+
data = self._transport.get(f"/dealers/{dealer_id}")
|
|
512
|
+
return DealerDetail.model_validate(data["data"])
|
|
513
|
+
|
|
514
|
+
def dealer_inventory(
|
|
515
|
+
self,
|
|
516
|
+
dealer_id: str,
|
|
517
|
+
filter: ListingsFilter | None = None,
|
|
518
|
+
) -> ListingsPage:
|
|
519
|
+
"""Return a single page of inventory for a specific dealer.
|
|
520
|
+
|
|
521
|
+
Accepts the same :class:`~visor.models.listings.ListingsFilter` shape
|
|
522
|
+
as :meth:`filter_listings`, so you can reuse a filter object across
|
|
523
|
+
both methods. Call this method in a loop advancing ``filter.offset`` to
|
|
524
|
+
paginate through all inventory pages.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
dealer_id: The unique dealer identifier.
|
|
528
|
+
filter: Optional search and pagination criteria.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
A :class:`~visor.models.listings.ListingsPage` of the dealer's
|
|
532
|
+
inventory.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
NotFoundError: No dealer with that ID exists (HTTP 404).
|
|
536
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
537
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
538
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
539
|
+
VisorAPIError: Any other API-level error.
|
|
540
|
+
VisorTransportError: Network-level failure.
|
|
541
|
+
"""
|
|
542
|
+
params = (filter or ListingsFilter()).to_params()
|
|
543
|
+
data = self._transport.get(f"/dealers/{dealer_id}/listings", params)
|
|
544
|
+
return ListingsPage.model_validate(data)
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------ #
|
|
547
|
+
# Usage #
|
|
548
|
+
# ------------------------------------------------------------------ #
|
|
549
|
+
|
|
550
|
+
def get_usage(
|
|
551
|
+
self,
|
|
552
|
+
start_date: date | None = None,
|
|
553
|
+
end_date: date | None = None,
|
|
554
|
+
metering_class: list[str] | None = None,
|
|
555
|
+
) -> UsageSummary:
|
|
556
|
+
"""Return API usage statistics for your account.
|
|
557
|
+
|
|
558
|
+
All parameters are optional. Omitting date bounds returns the full
|
|
559
|
+
available history. Omitting ``metering_class`` returns all endpoint
|
|
560
|
+
categories.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
start_date: Start of the reporting window (inclusive).
|
|
564
|
+
end_date: End of the reporting window (inclusive).
|
|
565
|
+
metering_class: Endpoint categories to include, e.g.
|
|
566
|
+
``["listings", "dealers"]``. Returns all categories when
|
|
567
|
+
omitted.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
A :class:`~visor.models.usage.UsageSummary` with request counts
|
|
571
|
+
and quota details.
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
AuthError: Invalid or missing API key (HTTP 401).
|
|
575
|
+
ForbiddenError: Key lacks access to this resource (HTTP 403).
|
|
576
|
+
RateLimitError: Rate limit exceeded; check ``.retry_after``.
|
|
577
|
+
VisorAPIError: Any other API-level error.
|
|
578
|
+
VisorTransportError: Network-level failure.
|
|
579
|
+
"""
|
|
580
|
+
params: dict[str, str] = {}
|
|
581
|
+
if start_date:
|
|
582
|
+
params["start_date"] = start_date.isoformat()
|
|
583
|
+
if end_date:
|
|
584
|
+
params["end_date"] = end_date.isoformat()
|
|
585
|
+
if metering_class:
|
|
586
|
+
params["metering_class"] = ",".join(metering_class)
|
|
587
|
+
data = self._transport.get("/usage", params)
|
|
588
|
+
return UsageSummary.model_validate(data)
|