Skip to content

whitewalls86/visor-python

Repository files navigation

visor-python

CI

visor-python is an unofficial community Python SDK for the Visor Public API — a vehicle inventory search platform covering new, used, and certified pre-owned listings from dealers across the US. It provides a thin, fully-typed wrapper around the REST API with sync and async clients, Pydantic response models, and auto-pagination helpers.

Disclaimer: This is an unofficial community SDK and is not affiliated with or endorsed by Visor (Currents Systems Inc.).

Pre-1.0 notice: This package is in initial development (0.x). Minor version bumps may include breaking changes. Pin to a specific minor version in production and review the CHANGELOG before upgrading.

Install

pip install visor-python

Requires Python 3.10+ and no non-standard runtime dependencies beyond httpx and pydantic.

Quick start

from visor import VisorClient, ListingsFilter, iter_listings

with VisorClient() as client:  # reads VISOR_API_KEY from env
    # Search for used Toyota Tacomas in Texas under $40k
    page = client.filter_listings(
        ListingsFilter(
            make=["Toyota"],
            model=["Tacoma"],
            inventory_type=["used"],
            state=["TX"],
            max_price=40_000,
        )
    )
    for listing in page.data:
        price = f"${listing.price:,}" if listing.price is not None else "N/A"
        print(f"{listing.year} {listing.make} {listing.model}{price}")

    # Look up a specific VIN
    vin = client.lookup_vin("4T1DAACKXTU765422", include=["price_history"])
    msrp = f"${vin.build.combined_msrp:,}" if vin.build.combined_msrp is not None else "N/A"
    print(msrp)

Geo filtering

Pass a postal_code and radius (miles) to search near a location:

page = client.filter_listings(
    ListingsFilter(
        make=["Honda"],
        model=["CR-V"],
        postal_code="90210",
        radius=50,
    )
)

radius requires exactly one of postal_code or latitude/longitude. Passing radius alone (or with neither) raises ValueError before any network call.

Paginating all results

iter_listings (sync) and paginate_listings (async) iterate every page automatically:

from visor import VisorClient, ListingsFilter, iter_listings

with VisorClient() as client:
    for listing in iter_listings(
        client,
        ListingsFilter(make=["Ford"], state=["TX"]),
    ):
        print(listing.vin, listing.price)

For dealers, use iter_dealers / paginate_dealers in the same way.

client.filter_listings(...) returns a single page (ListingsPage). Use the iter_* / paginate_* helpers when you need all results.

Async

import asyncio
from visor import AsyncVisorClient, ListingsFilter, paginate_listings

async def main() -> None:
    async with AsyncVisorClient() as client:
        # Single page
        page = await client.filter_listings(
            ListingsFilter(make=["Toyota"], state=["TX"], max_price=40_000)
        )
        for listing in page.data:
            price = f"${listing.price:,}" if listing.price is not None else "N/A"
            print(listing.vin, price)

        # All pages
        async for listing in paginate_listings(
            client,
            ListingsFilter(make=["Toyota"], state=["TX"]),
        ):
            print(listing.vin)

asyncio.run(main())

Configuration

API key

Pass your key explicitly or export VISOR_API_KEY before running:

client = VisorClient(api_key="vsr_live_...")
# or
# export VISOR_API_KEY=vsr_live_...
client = VisorClient()

You need your own Visor API key — see api.visor.vin for details. Use of the API is governed by Visor's API terms; you are responsible for complying with them.

Timeout

Default request timeout is 30 seconds. Override at construction time:

client = VisorClient(timeout=10.0)

Base URL (advanced)

base_url defaults to the production API. Override it for local testing or staging:

client = VisorClient(base_url="http://localhost:8080")

Key concepts

ListingsFilter is shared

ListingsFilter is accepted by both filter_listings() and dealer_inventory(). Build one filter object and reuse it across both methods.

fields is response projection, not filtering

ListingsFilter.fields controls which fields the API returns — it does not filter which listings match. Example:

filter = ListingsFilter(
    make=["Toyota"],
    fields=["vin", "price", "miles"],
)

id and vin are always returned by the API regardless of the fields projection.

Responses are Pydantic models

All responses — ListingsPage, ListingDetail, VinDetail, etc. — are Pydantic v2 models. Access fields as attributes and use standard Pydantic methods (.model_dump(), .model_json_schema(), etc.) as needed.

Optional include fields

Some fields are only populated when you request them via include=. The API uses three distinct states that the SDK preserves:

Value Meaning
None Section not requested — include= was not passed, or the API omitted the field
[] Section requested but no data exists
[...] Section requested and populated

Fields that follow this pattern:

  • ListingDetail.price_history — pass include=["price_history"] to get_listing()
  • ListingSnapshot.price_history — pass include=["price_history"] to lookup_vin()
  • VehicleBuild.options — pass include=["options"] to get_listing() or lookup_vin()
listing = client.get_listing(listing_id, include=["price_history"])

if listing.price_history is None:
    print("price history was not requested")
elif listing.price_history == []:
    print("requested but no price changes recorded")
else:
    for entry in listing.price_history:
        print(entry.date, entry.price)

ListingSummary.price_history and ListingSummary.options are also populated via ListingsFilter(include=...), but they default to [] when the API omits the key. For summary listings, [] can mean either "not present in the response" or "requested and empty"; use detail/VIN lookups if you need the three-state None / [] / populated distinction.

Error handling

All methods raise typed exceptions from visor.exceptions. The SDK does not retry automatically — RateLimitError.retry_after gives you the hint to build your own retry logic.

import time
from visor import VisorClient, ListingsFilter, RateLimitError, VisorAPIError

def fetch_with_backoff(client: VisorClient, f: ListingsFilter) -> object:
    for attempt in range(4):
        try:
            return client.filter_listings(f)
        except RateLimitError as e:
            wait = e.retry_after if e.retry_after is not None else 2 ** attempt
            print(f"Rate limited — waiting {wait}s")
            time.sleep(wait)
        except VisorAPIError as e:
            raise  # surface non-rate-limit errors immediately
    raise RuntimeError("Exhausted retries")

Exception hierarchy:

Exception When
VisorAPIError Base for all API errors; has .status_code
AuthError 401 — invalid or missing API key
NotFoundError 404 — resource does not exist
RateLimitError 429 — includes .retry_after (seconds, or None)

Debugging

Inspect the exceptionVisorAPIError carries .status_code and a message from the API.

Check retry_after — for RateLimitError, .retry_after is the number of seconds to wait (or None if the API did not provide a value).

Request-level logging — visor-python uses httpx internally. Enable httpx logging to see raw requests and responses:

import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.DEBUG)

Community

License

MIT

About

Unoffical community Python SDK for the Visor Public API

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors

Languages