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.
pip install visor-pythonRequires Python 3.10+ and no non-standard runtime dependencies beyond httpx and pydantic.
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)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.
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.
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())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.
Default request timeout is 30 seconds. Override at construction time:
client = VisorClient(timeout=10.0)base_url defaults to the production API. Override it for local testing or staging:
client = VisorClient(base_url="http://localhost:8080")ListingsFilter is accepted by both filter_listings() and dealer_inventory(). Build one filter object and reuse it across both methods.
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.
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.
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— passinclude=["price_history"]toget_listing()ListingSnapshot.price_history— passinclude=["price_history"]tolookup_vin()VehicleBuild.options— passinclude=["options"]toget_listing()orlookup_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.
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) |
Inspect the exception — VisorAPIError 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)- CONTRIBUTING.md — how to contribute
- CODE_OF_CONDUCT.md — community standards
- GitHub Issues — bug reports and feature requests
MIT