From afe2c5228318dfdc3dd66b550399e22d16a2f384 Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Sat, 13 Jun 2026 12:15:25 -0500 Subject: [PATCH 1/3] Add public API docstrings and 401/403 error message polish (step 17) - AsyncVisorClient and VisorClient: class docstrings covering context-manager usage, thread-safety note (sync), and constructor args; method docstrings on all public methods documenting one-page semantics, pagination helper refs, return types, and raised exceptions - exceptions.py: expanded AuthError (401 vs key issues), ForbiddenError (403 vs permissions), and RateLimitError (retry_after attribute semantics) docstrings - _transport.py: fallback actionable messages for empty 401/403 response bodies; API-provided messages are always preferred - tests/test_transport.py: parametrized sync and async tests for the new empty-body fallback paths Co-Authored-By: Claude Sonnet 4.6 --- src/visor/_client.py | 374 ++++++++++++++++++++++++++++++++++++++++ src/visor/_transport.py | 12 +- src/visor/exceptions.py | 28 ++- tests/test_transport.py | 42 +++++ 4 files changed, 451 insertions(+), 5 deletions(-) diff --git a/src/visor/_client.py b/src/visor/_client.py index c4cad2a..41b8e66 100644 --- a/src/visor/_client.py +++ b/src/visor/_client.py @@ -11,6 +11,24 @@ class AsyncVisorClient: + """Async client for the Visor Public API. + + All methods are coroutines and must be awaited. Use as an async context + manager to ensure the underlying HTTP connection pool is closed: + + async with AsyncVisorClient() as client: + page = await client.filter_listings(...) + + Args: + api_key: Visor API key. Defaults to the ``VISOR_API_KEY`` environment + variable. + timeout: Request timeout in seconds. Defaults to 30. + base_url: API base URL. Override for local testing or staging. + + Raises: + ValueError: If no API key is provided or found in the environment. + """ + def __init__( self, api_key: str | None = None, @@ -31,6 +49,7 @@ async def __aexit__(self, *args: object) -> None: await self.aclose() async def aclose(self) -> None: + """Close the underlying HTTP client and release connections.""" await self._transport.aclose() # ------------------------------------------------------------------ # @@ -40,6 +59,26 @@ async def aclose(self) -> None: async def filter_listings( self, filter: ListingsFilter | None = None ) -> ListingsPage: + """Return a single page of listings matching the given filter. + + To iterate all results across pages, use :func:`visor.paginate_listings` + instead. + + Args: + filter: Search and pagination criteria. Defaults to an empty filter + (first page, no constraints). + + Returns: + A :class:`~visor.models.listings.ListingsPage` containing the + matched listings and pagination metadata. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure (timeout, connection). + """ params = (filter or ListingsFilter()).to_params() data = await self._transport.get("/listings", params) return ListingsPage.model_validate(data) @@ -49,6 +88,26 @@ async def get_listing( listing_id: str, include: list[Literal["price_history", "options"]] | None = None, ) -> ListingDetail: + """Return full detail for a single listing by its ID. + + Args: + listing_id: The unique listing identifier. + include: Optional extra sections to embed in the response. + ``"price_history"`` adds historical price records; + ``"options"`` adds option/package details. + + Returns: + A :class:`~visor.models.listings.ListingDetail` for the requested + listing. + + Raises: + NotFoundError: No listing with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if include: params["include"] = ",".join(include) @@ -60,6 +119,29 @@ async def lookup_vin( vin: str, include: list[Literal["price_history", "options"]] | None = None, ) -> VinDetail: + """Return build and listing information for a VIN. + + The returned :class:`~visor.models.vins.VinDetail` always includes + ``vin``, ``status``, and ``build``. The ``latest_listing`` field is + ``None`` when no active or recent listing exists for the VIN. + + Args: + vin: 17-character Vehicle Identification Number. + include: Optional extra sections to embed. ``"price_history"`` + adds historical price records; ``"options"`` adds option + details on the latest listing. + + Returns: + A :class:`~visor.models.vins.VinDetail` for the requested VIN. + + Raises: + NotFoundError: VIN not found in the Visor database (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if include: params["include"] = ",".join(include) @@ -67,6 +149,25 @@ async def lookup_vin( return VinDetail.model_validate(data["data"]) async def filter_facets(self, filter: FacetsFilter) -> FacetsResponse: + """Return facet aggregations for the given filter. + + Facets summarize available field values and ranges across all listings + matching the filter, useful for building search-UI refinement panels. + + Args: + filter: Facet query criteria, including which facets to compute. + + Returns: + A :class:`~visor.models.facets.FacetsResponse` with aggregation + data for each requested facet. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ data = await self._transport.get("/facets", filter.to_params()) return FacetsResponse.model_validate(data) @@ -75,11 +176,48 @@ async def filter_facets(self, filter: FacetsFilter) -> FacetsResponse: # ------------------------------------------------------------------ # async def search_dealers(self, filter: DealerFilter | None = None) -> DealersPage: + """Return a single page of dealers matching the given filter. + + To iterate all dealers across pages, use :func:`visor.paginate_dealers` + instead. + + Args: + filter: Search criteria and pagination parameters. Defaults to an + empty filter (first page, no constraints). + + Returns: + A :class:`~visor.models.dealers.DealersPage` containing matched + dealers and pagination metadata. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params = (filter or DealerFilter()).to_params() data = await self._transport.get("/dealers", params) return DealersPage.model_validate(data) async def get_dealer(self, dealer_id: str) -> DealerDetail: + """Return full detail for a single dealer by its ID. + + Args: + dealer_id: The unique dealer identifier. + + Returns: + A :class:`~visor.models.dealers.DealerDetail` for the requested + dealer. + + Raises: + NotFoundError: No dealer with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ data = await self._transport.get(f"/dealers/{dealer_id}") return DealerDetail.model_validate(data["data"]) @@ -88,6 +226,29 @@ async def dealer_inventory( dealer_id: str, filter: ListingsFilter | None = None, ) -> ListingsPage: + """Return a single page of inventory for a specific dealer. + + Accepts the same :class:`~visor.models.listings.ListingsFilter` shape + as :meth:`filter_listings`, so you can reuse a filter object across + both methods. To iterate all inventory pages, use + :func:`visor.paginate_listings` with the ``dealer_id`` parameter. + + Args: + dealer_id: The unique dealer identifier. + filter: Optional search and pagination criteria. + + Returns: + A :class:`~visor.models.listings.ListingsPage` of the dealer's + inventory. + + Raises: + NotFoundError: No dealer with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params = (filter or ListingsFilter()).to_params() data = await self._transport.get(f"/dealers/{dealer_id}/listings", params) return ListingsPage.model_validate(data) @@ -102,6 +263,30 @@ async def get_usage( end_date: date | None = None, metering_class: list[str] | None = None, ) -> UsageSummary: + """Return API usage statistics for your account. + + All parameters are optional. Omitting date bounds returns the full + available history. Omitting ``metering_class`` returns all endpoint + categories. + + Args: + start_date: Start of the reporting window (inclusive). + end_date: End of the reporting window (inclusive). + metering_class: Endpoint categories to include, e.g. + ``["listings", "dealers"]``. Returns all categories when + omitted. + + Returns: + A :class:`~visor.models.usage.UsageSummary` with request counts + and quota details. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if start_date: params["start_date"] = start_date.isoformat() @@ -114,6 +299,28 @@ async def get_usage( class VisorClient: + """Synchronous client for the Visor Public API. + + All methods block until the HTTP response is received. The client owns an + internal :class:`httpx.Client` with a connection pool; it is **not** + thread-safe. If you share a single ``VisorClient`` across threads, you must + add external synchronization — or create one client per thread instead. + + Use as a context manager to ensure the connection pool is released: + + with VisorClient() as client: + page = client.filter_listings(...) + + Args: + api_key: Visor API key. Defaults to the ``VISOR_API_KEY`` environment + variable. + timeout: Request timeout in seconds. Defaults to 30. + base_url: API base URL. Override for local testing or staging. + + Raises: + ValueError: If no API key is provided or found in the environment. + """ + def __init__( self, api_key: str | None = None, @@ -134,6 +341,7 @@ def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: + """Close the underlying HTTP client and release connections.""" self._transport.close() # ------------------------------------------------------------------ # @@ -141,6 +349,26 @@ def close(self) -> None: # ------------------------------------------------------------------ # def filter_listings(self, filter: ListingsFilter | None = None) -> ListingsPage: + """Return a single page of listings matching the given filter. + + To iterate all results across pages, use :func:`visor.iter_listings` + instead. + + Args: + filter: Search and pagination criteria. Defaults to an empty filter + (first page, no constraints). + + Returns: + A :class:`~visor.models.listings.ListingsPage` containing the + matched listings and pagination metadata. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure (timeout, connection). + """ params = (filter or ListingsFilter()).to_params() data = self._transport.get("/listings", params) return ListingsPage.model_validate(data) @@ -150,6 +378,26 @@ def get_listing( listing_id: str, include: list[Literal["price_history", "options"]] | None = None, ) -> ListingDetail: + """Return full detail for a single listing by its ID. + + Args: + listing_id: The unique listing identifier. + include: Optional extra sections to embed in the response. + ``"price_history"`` adds historical price records; + ``"options"`` adds option/package details. + + Returns: + A :class:`~visor.models.listings.ListingDetail` for the requested + listing. + + Raises: + NotFoundError: No listing with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if include: params["include"] = ",".join(include) @@ -161,6 +409,29 @@ def lookup_vin( vin: str, include: list[Literal["price_history", "options"]] | None = None, ) -> VinDetail: + """Return build and listing information for a VIN. + + The returned :class:`~visor.models.vins.VinDetail` always includes + ``vin``, ``status``, and ``build``. The ``latest_listing`` field is + ``None`` when no active or recent listing exists for the VIN. + + Args: + vin: 17-character Vehicle Identification Number. + include: Optional extra sections to embed. ``"price_history"`` + adds historical price records; ``"options"`` adds option + details on the latest listing. + + Returns: + A :class:`~visor.models.vins.VinDetail` for the requested VIN. + + Raises: + NotFoundError: VIN not found in the Visor database (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if include: params["include"] = ",".join(include) @@ -168,6 +439,25 @@ def lookup_vin( return VinDetail.model_validate(data["data"]) def filter_facets(self, filter: FacetsFilter) -> FacetsResponse: + """Return facet aggregations for the given filter. + + Facets summarize available field values and ranges across all listings + matching the filter, useful for building search-UI refinement panels. + + Args: + filter: Facet query criteria, including which facets to compute. + + Returns: + A :class:`~visor.models.facets.FacetsResponse` with aggregation + data for each requested facet. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ data = self._transport.get("/facets", filter.to_params()) return FacetsResponse.model_validate(data) @@ -176,11 +466,48 @@ def filter_facets(self, filter: FacetsFilter) -> FacetsResponse: # ------------------------------------------------------------------ # def search_dealers(self, filter: DealerFilter | None = None) -> DealersPage: + """Return a single page of dealers matching the given filter. + + To iterate all dealers across pages, use :func:`visor.iter_dealers` + instead. + + Args: + filter: Search criteria and pagination parameters. Defaults to an + empty filter (first page, no constraints). + + Returns: + A :class:`~visor.models.dealers.DealersPage` containing matched + dealers and pagination metadata. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params = (filter or DealerFilter()).to_params() data = self._transport.get("/dealers", params) return DealersPage.model_validate(data) def get_dealer(self, dealer_id: str) -> DealerDetail: + """Return full detail for a single dealer by its ID. + + Args: + dealer_id: The unique dealer identifier. + + Returns: + A :class:`~visor.models.dealers.DealerDetail` for the requested + dealer. + + Raises: + NotFoundError: No dealer with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ data = self._transport.get(f"/dealers/{dealer_id}") return DealerDetail.model_validate(data["data"]) @@ -189,6 +516,29 @@ def dealer_inventory( dealer_id: str, filter: ListingsFilter | None = None, ) -> ListingsPage: + """Return a single page of inventory for a specific dealer. + + Accepts the same :class:`~visor.models.listings.ListingsFilter` shape + as :meth:`filter_listings`, so you can reuse a filter object across + both methods. To iterate all inventory pages, use + :func:`visor.iter_listings` with the ``dealer_id`` parameter. + + Args: + dealer_id: The unique dealer identifier. + filter: Optional search and pagination criteria. + + Returns: + A :class:`~visor.models.listings.ListingsPage` of the dealer's + inventory. + + Raises: + NotFoundError: No dealer with that ID exists (HTTP 404). + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params = (filter or ListingsFilter()).to_params() data = self._transport.get(f"/dealers/{dealer_id}/listings", params) return ListingsPage.model_validate(data) @@ -203,6 +553,30 @@ def get_usage( end_date: date | None = None, metering_class: list[str] | None = None, ) -> UsageSummary: + """Return API usage statistics for your account. + + All parameters are optional. Omitting date bounds returns the full + available history. Omitting ``metering_class`` returns all endpoint + categories. + + Args: + start_date: Start of the reporting window (inclusive). + end_date: End of the reporting window (inclusive). + metering_class: Endpoint categories to include, e.g. + ``["listings", "dealers"]``. Returns all categories when + omitted. + + Returns: + A :class:`~visor.models.usage.UsageSummary` with request counts + and quota details. + + Raises: + AuthError: Invalid or missing API key (HTTP 401). + ForbiddenError: Key lacks access to this resource (HTTP 403). + RateLimitError: Rate limit exceeded; check ``.retry_after``. + VisorAPIError: Any other API-level error. + VisorTransportError: Network-level failure. + """ params: dict[str, str] = {} if start_date: params["start_date"] = start_date.isoformat() diff --git a/src/visor/_transport.py b/src/visor/_transport.py index b785205..20d5828 100644 --- a/src/visor/_transport.py +++ b/src/visor/_transport.py @@ -51,11 +51,19 @@ def _handle_response(response: httpx.Response) -> dict: # type: ignore[type-arg case 400: raise ValidationError(400, error_code, message) case 401: - raise AuthError(401, error_code, message) + raise AuthError( + 401, + error_code, + message or "Invalid or missing API key. Check VISOR_API_KEY.", + ) case 402: raise PaymentRequiredError(402, error_code, message) case 403: - raise ForbiddenError(403, error_code, message) + raise ForbiddenError( + 403, + error_code, + message or "Access denied. Key lacks permission for this resource.", + ) case 404: raise NotFoundError(404, error_code, message) case 429: diff --git a/src/visor/exceptions.py b/src/visor/exceptions.py index 5ea6fcb..8509792 100644 --- a/src/visor/exceptions.py +++ b/src/visor/exceptions.py @@ -13,11 +13,21 @@ def __init__(self, status_code: int, error_code: str, message: str) -> None: class AuthError(VisorAPIError): - """401 — authentication failed.""" + """HTTP 401 — the request was not authenticated. + + Raised when the API key is missing, malformed, or invalid. Check that + ``VISOR_API_KEY`` is set to a valid key, or pass ``api_key`` explicitly + when constructing the client. + """ class ForbiddenError(VisorAPIError): - """403 — access denied.""" + """HTTP 403 — the request was authenticated but not authorized. + + Raised when a valid API key does not have permission to access the + requested resource or perform the requested action. Contact Visor support + if you believe you should have access. + """ class NotFoundError(VisorAPIError): @@ -33,7 +43,19 @@ class PaymentRequiredError(VisorAPIError): class RateLimitError(VisorAPIError): - """429 — rate limit exceeded.""" + """HTTP 429 — the API rate limit has been exceeded. + + Attributes: + retry_after: Number of seconds to wait before retrying, or ``None`` + if the API did not include a usable ``Retry-After`` header. When + present the value is derived from an integer seconds value or an + HTTP-date header, whichever the API provides. Build your retry + loop around this value: + + except RateLimitError as e: + wait = e.retry_after if e.retry_after is not None else 2 ** attempt + time.sleep(wait) + """ def __init__( self, diff --git a/tests/test_transport.py b/tests/test_transport.py index 69dac7a..7abe671 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -285,3 +285,45 @@ def test_sync_request_error_raises_transport_error(): with pytest.raises(VisorTransportError): transport.get("/listings") transport.close() + + +# --------------------------------------------------------------------------- +# Fallback messages for empty 401 / 403 bodies +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "status_code, exc_class", [(401, AuthError), (403, ForbiddenError)] +) +def test_sync_empty_body_fallback_message(status_code: int, exc_class: type) -> None: + """When the API returns 401/403 with no body, a useful default message is set.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, content=b"") + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code, exc_class", [(401, AuthError), (403, ForbiddenError)] +) +async def test_async_empty_body_fallback_message( + status_code: int, exc_class: type +) -> None: + """When the API returns 401/403 with no body, a useful default message is set.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, content=b"") + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.message From 18008c7a7f6e37b72152af3d7c055ed091eea300 Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Sat, 13 Jun 2026 12:22:52 -0500 Subject: [PATCH 2/3] Fix incorrect dealer_inventory pagination hint in docstrings The previous docstrings told users to call paginate_listings/iter_listings with a dealer_id parameter that does not exist on those helpers. Replaced with a correct manual-pagination note (advance filter.page in a loop). Co-Authored-By: Claude Sonnet 4.6 --- src/visor/_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/visor/_client.py b/src/visor/_client.py index 41b8e66..0972314 100644 --- a/src/visor/_client.py +++ b/src/visor/_client.py @@ -230,8 +230,8 @@ async def dealer_inventory( Accepts the same :class:`~visor.models.listings.ListingsFilter` shape as :meth:`filter_listings`, so you can reuse a filter object across - both methods. To iterate all inventory pages, use - :func:`visor.paginate_listings` with the ``dealer_id`` parameter. + both methods. Call this method in a loop advancing ``filter.page`` to + paginate through all inventory pages. Args: dealer_id: The unique dealer identifier. @@ -520,8 +520,8 @@ def dealer_inventory( Accepts the same :class:`~visor.models.listings.ListingsFilter` shape as :meth:`filter_listings`, so you can reuse a filter object across - both methods. To iterate all inventory pages, use - :func:`visor.iter_listings` with the ``dealer_id`` parameter. + both methods. Call this method in a loop advancing ``filter.page`` to + paginate through all inventory pages. Args: dealer_id: The unique dealer identifier. From 51213bc715367d38c6a695ac474e19f32f253e51 Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Sat, 13 Jun 2026 12:24:23 -0500 Subject: [PATCH 3/3] Fix dealer_inventory pagination hint: page -> offset The SDK uses ListingsFilter.offset for pagination, not .page. Co-Authored-By: Claude Sonnet 4.6 --- src/visor/_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/visor/_client.py b/src/visor/_client.py index 0972314..20f60a6 100644 --- a/src/visor/_client.py +++ b/src/visor/_client.py @@ -230,7 +230,7 @@ async def dealer_inventory( Accepts the same :class:`~visor.models.listings.ListingsFilter` shape as :meth:`filter_listings`, so you can reuse a filter object across - both methods. Call this method in a loop advancing ``filter.page`` to + both methods. Call this method in a loop advancing ``filter.offset`` to paginate through all inventory pages. Args: @@ -520,7 +520,7 @@ def dealer_inventory( Accepts the same :class:`~visor.models.listings.ListingsFilter` shape as :meth:`filter_listings`, so you can reuse a filter object across - both methods. Call this method in a loop advancing ``filter.page`` to + both methods. Call this method in a loop advancing ``filter.offset`` to paginate through all inventory pages. Args: