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/models/_base.py ADDED
@@ -0,0 +1,353 @@
1
+ from datetime import date
2
+ from enum import Enum
3
+ from typing import Any, NamedTuple
4
+
5
+ from pydantic import BaseModel, ConfigDict, model_validator
6
+
7
+
8
+ class VisorRequestModel(BaseModel):
9
+ """Base for request/filter models. The API fails closed on unknown params."""
10
+
11
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
12
+
13
+
14
+ class VisorResponseModel(BaseModel):
15
+ """Base for API responses. Beta responses may add fields over time."""
16
+
17
+ model_config = ConfigDict(extra="ignore")
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Shared response building blocks
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ class VehicleOption(VisorResponseModel):
26
+ code: str
27
+ name: str
28
+ msrp: float | None = None
29
+
30
+
31
+ class VehicleBuild(VisorResponseModel):
32
+ year: int
33
+ make: str
34
+ model: str
35
+ trim: str | None = None
36
+ version: str | None = None
37
+ body_type: str | None = None
38
+ drivetrain: str | None = None
39
+ fuel_type: str | None = None
40
+ powertrain_type: str | None = None
41
+ transmission: str | None = None
42
+ engine: str | None = None
43
+ cylinders: int | None = None
44
+ doors: int | None = None
45
+ seating_capacity: int | None = None
46
+ exterior_color: str | None = None
47
+ interior_color: str | None = None
48
+ base_exterior_color: str | None = None
49
+ base_interior_color: str | None = None
50
+ assembly_location: str | None = None
51
+ assembly_country: str | None = None
52
+ window_sticker_verified: bool = False
53
+ base_msrp: int | None = None
54
+ combined_msrp: int | None = None
55
+ options: list[VehicleOption] | None = None
56
+
57
+
58
+ class VehicleRecord(VisorResponseModel):
59
+ """VIN-level wrapper as returned inside ListingDetail.vehicle."""
60
+
61
+ vin: str
62
+ status: str
63
+ build: VehicleBuild
64
+
65
+
66
+ class PriceHistoryEntry(VisorResponseModel):
67
+ date: date
68
+ price: int
69
+
70
+
71
+ class DealerRef(VisorResponseModel):
72
+ dealer_id: str
73
+ name: str
74
+ city: str
75
+ state: str
76
+ latitude: float | None = None
77
+ longitude: float | None = None
78
+ phone: str | None = None
79
+
80
+
81
+ class Pagination(VisorResponseModel):
82
+ limit: int
83
+ offset: int
84
+ total: int
85
+ next_offset: int | None = None
86
+
87
+
88
+ class BBox(NamedTuple):
89
+ """Bounding box for map-viewport filtering: west, south, east, north."""
90
+
91
+ west: float
92
+ south: float
93
+ east: float
94
+ north: float
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Filter enums
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ class InventoryMode(str, Enum):
103
+ ACTIVE = "active"
104
+ SOLD = "sold"
105
+
106
+
107
+ class SortOrder(str, Enum):
108
+ DAYS_ON_MARKET = "days_on_market"
109
+ DAYS_ON_MARKET_DESC = "-days_on_market"
110
+ PRICE = "price"
111
+ PRICE_DESC = "-price"
112
+ MILES = "miles"
113
+ MILES_DESC = "-miles"
114
+ MSRP = "msrp"
115
+ MSRP_DESC = "-msrp"
116
+ DISCOUNT = "discount"
117
+ DISCOUNT_DESC = "-discount"
118
+ DISTANCE = "distance"
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # ListingsFilterBase — shared by ListingsFilter and FacetsFilter
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ class ListingsFilterBase(VisorRequestModel):
127
+ # Categorical filters (comma-separated on the wire)
128
+ make: list[str] | None = None
129
+ model: list[str] | None = None
130
+ trim: list[str] | None = None
131
+ year: list[int] | None = None
132
+ state: list[str] | None = None
133
+ dealer_id: list[str] | None = None
134
+ dealer_type: list[str] | None = None
135
+ availability_status: list[str] | None = None
136
+ inventory_type: list[str] | None = None
137
+ body_type: list[str] | None = None
138
+ transmission: list[str] | None = None
139
+ drivetrain: list[str] | None = None
140
+ fuel_type: list[str] | None = None
141
+ powertrain_type: list[str] | None = None
142
+ engine: list[str] | None = None
143
+ version: list[str] | None = None
144
+ exterior_color: list[str] | None = None
145
+ interior_color: list[str] | None = None
146
+ base_exterior_color: list[str] | None = None
147
+ base_interior_color: list[str] | None = None
148
+ seating_capacity: list[int] | None = None
149
+ cylinders: list[int] | None = None
150
+ doors: list[int] | None = None
151
+ options_packages: list[str] | None = None
152
+ features: list[str] | None = None
153
+ keywords: list[str] | None = None
154
+ vin_pattern: list[str] | None = None
155
+
156
+ # assembly_location uses PIPE separator (not comma)
157
+ assembly_location: list[str] | None = None
158
+ assembly_country: list[str] | None = None
159
+
160
+ # Exclude counterparts (comma-separated, EXCEPT assembly_location uses "+")
161
+ exclude_make: list[str] | None = None
162
+ exclude_model: list[str] | None = None
163
+ exclude_trim: list[str] | None = None
164
+ exclude_year: list[int] | None = None
165
+ exclude_state: list[str] | None = None
166
+ exclude_inventory_type: list[str] | None = None
167
+ exclude_body_type: list[str] | None = None
168
+ exclude_transmission: list[str] | None = None
169
+ exclude_drivetrain: list[str] | None = None
170
+ exclude_version: list[str] | None = None
171
+ exclude_engine: list[str] | None = None
172
+ exclude_assembly_location: list[str] | None = None # plus-separated on the wire
173
+ exclude_assembly_country: list[str] | None = None
174
+ exclude_exterior_color: list[str] | None = None
175
+ exclude_interior_color: list[str] | None = None
176
+ exclude_base_exterior_color: list[str] | None = None
177
+ exclude_base_interior_color: list[str] | None = None
178
+ exclude_options_packages: list[str] | None = None
179
+ exclude_features: list[str] | None = None
180
+ exclude_fuel_type: list[str] | None = None
181
+ exclude_powertrain_type: list[str] | None = None
182
+ exclude_keywords: list[str] | None = None
183
+
184
+ # Range filters (integers, serialized as strings)
185
+ min_price: int | None = None
186
+ max_price: int | None = None
187
+ min_mileage: int | None = None
188
+ max_mileage: int | None = None
189
+ min_msrp: int | None = None
190
+ max_msrp: int | None = None
191
+ min_days_on_market: int | None = None
192
+ max_days_on_market: int | None = None
193
+
194
+ # Inventory mode
195
+ inventory_status: InventoryMode = InventoryMode.ACTIVE
196
+ sold_within_days: int | None = None
197
+ snapshot_date: date | None = None
198
+
199
+ # Geo
200
+ postal_code: str | None = None
201
+ latitude: float | None = None
202
+ longitude: float | None = None
203
+ radius: float | None = None
204
+ bbox: BBox | None = None
205
+
206
+ @model_validator(mode="after")
207
+ def _validate_geo_and_inventory(self) -> "ListingsFilterBase":
208
+ if self.radius is not None:
209
+ has_postal = self.postal_code is not None
210
+ has_latlon = self.latitude is not None and self.longitude is not None
211
+ if not (has_postal ^ has_latlon):
212
+ raise ValueError(
213
+ "radius requires exactly one of postal_code"
214
+ " or (latitude + longitude)"
215
+ )
216
+ if self.bbox is not None and self.radius is not None:
217
+ raise ValueError("bbox and radius are mutually exclusive")
218
+ if (
219
+ self.sold_within_days is not None
220
+ and self.inventory_status != InventoryMode.SOLD
221
+ ):
222
+ raise ValueError("sold_within_days requires inventory_status='sold'")
223
+ if (
224
+ self.snapshot_date is not None
225
+ and self.inventory_status != InventoryMode.ACTIVE
226
+ ):
227
+ raise ValueError("snapshot_date requires inventory_status='active'")
228
+ # NOTE: this branch is unreachable in practice — earlier checks already
229
+ # enforce sold_within_days→SOLD and snapshot_date→ACTIVE, making both
230
+ # non-None simultaneously impossible. Kept as a logical guard.
231
+ if self.sold_within_days is not None and self.snapshot_date is not None:
232
+ raise ValueError(
233
+ "sold_within_days and snapshot_date are mutually exclusive"
234
+ )
235
+ return self
236
+
237
+ def to_params(self) -> dict[str, str]:
238
+ """Serialize to flat query-string params, handling all separator quirks."""
239
+ params: dict[str, str] = {}
240
+
241
+ def comma(field: str, values: list[Any]) -> None:
242
+ if values:
243
+ params[field] = ",".join(str(v) for v in values)
244
+
245
+ def pipe(field: str, values: list[Any]) -> None:
246
+ if values:
247
+ params[field] = "|".join(str(v) for v in values)
248
+
249
+ def plus(field: str, values: list[Any]) -> None:
250
+ if values:
251
+ params[field] = "+".join(str(v) for v in values)
252
+
253
+ # Categorical fields (comma-separated)
254
+ for field in [
255
+ "make",
256
+ "model",
257
+ "trim",
258
+ "state",
259
+ "dealer_id",
260
+ "dealer_type",
261
+ "availability_status",
262
+ "inventory_type",
263
+ "body_type",
264
+ "transmission",
265
+ "drivetrain",
266
+ "fuel_type",
267
+ "powertrain_type",
268
+ "engine",
269
+ "version",
270
+ "exterior_color",
271
+ "interior_color",
272
+ "base_exterior_color",
273
+ "base_interior_color",
274
+ "options_packages",
275
+ "features",
276
+ "keywords",
277
+ "vin_pattern",
278
+ "assembly_country",
279
+ "exclude_make",
280
+ "exclude_model",
281
+ "exclude_trim",
282
+ "exclude_state",
283
+ "exclude_inventory_type",
284
+ "exclude_body_type",
285
+ "exclude_transmission",
286
+ "exclude_drivetrain",
287
+ "exclude_version",
288
+ "exclude_engine",
289
+ "exclude_assembly_country",
290
+ "exclude_exterior_color",
291
+ "exclude_interior_color",
292
+ "exclude_base_exterior_color",
293
+ "exclude_base_interior_color",
294
+ "exclude_options_packages",
295
+ "exclude_features",
296
+ "exclude_fuel_type",
297
+ "exclude_powertrain_type",
298
+ "exclude_keywords",
299
+ ]:
300
+ val = getattr(self, field)
301
+ if val is not None:
302
+ comma(field, val)
303
+
304
+ # Integer list fields (year, seating_capacity, etc.) — same comma join
305
+ for field in ["year", "seating_capacity", "cylinders", "doors", "exclude_year"]:
306
+ val = getattr(self, field)
307
+ if val is not None:
308
+ comma(field, val)
309
+
310
+ # Special separators
311
+ if self.assembly_location:
312
+ pipe("assembly_location", self.assembly_location)
313
+ if self.exclude_assembly_location:
314
+ plus("exclude_assembly_location", self.exclude_assembly_location)
315
+
316
+ # Range filters
317
+ for field in [
318
+ "min_price",
319
+ "max_price",
320
+ "min_mileage",
321
+ "max_mileage",
322
+ "min_msrp",
323
+ "max_msrp",
324
+ "min_days_on_market",
325
+ "max_days_on_market",
326
+ ]:
327
+ val = getattr(self, field)
328
+ if val is not None:
329
+ params[field] = str(val)
330
+
331
+ # Inventory mode (omit default to keep URLs clean)
332
+ if self.inventory_status != InventoryMode.ACTIVE:
333
+ params["inventory_status"] = self.inventory_status.value
334
+ if self.sold_within_days is not None:
335
+ params["sold_within_days"] = str(self.sold_within_days)
336
+ if self.snapshot_date is not None:
337
+ params["snapshot_date"] = self.snapshot_date.isoformat()
338
+
339
+ # Geo
340
+ if self.postal_code is not None:
341
+ params["postal_code"] = self.postal_code
342
+ if self.latitude is not None:
343
+ params["latitude"] = str(self.latitude)
344
+ if self.longitude is not None:
345
+ params["longitude"] = str(self.longitude)
346
+ if self.radius is not None:
347
+ params["radius"] = str(self.radius)
348
+ if self.bbox is not None:
349
+ params["bbox"] = (
350
+ f"{self.bbox.west},{self.bbox.south},{self.bbox.east},{self.bbox.north}"
351
+ )
352
+
353
+ return params
@@ -0,0 +1,81 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from visor.models._base import Pagination, VisorRequestModel, VisorResponseModel
6
+
7
+
8
+ class DealerFilter(VisorRequestModel):
9
+ limit: int = 50
10
+ offset: int = 0
11
+ dealer_id: list[str] | None = None
12
+ state: list[str] | None = None
13
+ country: str | None = None
14
+ type: Literal["franchise", "independent"] | None = None
15
+ make: list[str] | None = None
16
+ q: str | None = None
17
+
18
+ @field_validator("limit")
19
+ @classmethod
20
+ def _limit_max(cls, v: int) -> int:
21
+ if v > 100:
22
+ raise ValueError("limit maximum is 100")
23
+ return v
24
+
25
+ @field_validator("dealer_id")
26
+ @classmethod
27
+ def _dealer_id_max(cls, v: list[str] | None) -> list[str] | None:
28
+ if v is not None and len(v) > 100:
29
+ raise ValueError("dealer_id maximum is 100 entries")
30
+ return v
31
+
32
+ def to_params(self) -> dict[str, str]:
33
+ params: dict[str, str] = {
34
+ "limit": str(self.limit),
35
+ "offset": str(self.offset),
36
+ }
37
+ if self.dealer_id:
38
+ params["dealer_id"] = ",".join(self.dealer_id)
39
+ if self.state:
40
+ params["state"] = ",".join(self.state)
41
+ if self.country:
42
+ params["country"] = self.country
43
+ if self.type:
44
+ params["type"] = self.type
45
+ if self.make:
46
+ params["make"] = ",".join(self.make)
47
+ if self.q:
48
+ params["q"] = self.q
49
+ return params
50
+
51
+
52
+ class DealerAddress(VisorResponseModel):
53
+ line1: str | None = None
54
+ city: str
55
+ state: str
56
+ country: str
57
+
58
+
59
+ class DealerSummary(VisorResponseModel):
60
+ dealer_id: str
61
+ name: str
62
+ city: str
63
+ state: str
64
+ country: str
65
+ latitude: float | None = None
66
+ longitude: float | None = None
67
+ type: str
68
+ website: str | None = None
69
+ makes: list[str] = Field(default_factory=list)
70
+ listing_count: int
71
+
72
+
73
+ class DealerDetail(DealerSummary):
74
+ phone: str | None = None
75
+ address: DealerAddress | None = None
76
+
77
+
78
+ class DealersPage(VisorResponseModel):
79
+ data: list[DealerSummary]
80
+ pagination: Pagination
81
+ meta: dict[str, object] = Field(default_factory=dict)
visor/models/facets.py ADDED
@@ -0,0 +1,142 @@
1
+ from typing import Literal, TypeAlias
2
+
3
+ from pydantic import Field, field_validator, model_validator
4
+
5
+ from visor.models._base import ListingsFilterBase, VisorResponseModel
6
+
7
+ FACET_NAMES = {
8
+ "make",
9
+ "model",
10
+ "inventory_type",
11
+ "year",
12
+ "trim",
13
+ "version",
14
+ "base_exterior_color",
15
+ "exterior_color",
16
+ "base_interior_color",
17
+ "interior_color",
18
+ "seating_capacity",
19
+ "doors",
20
+ "engine",
21
+ "state",
22
+ "drivetrain",
23
+ "assembly_location",
24
+ "assembly_country",
25
+ "transmission",
26
+ "fuel_type",
27
+ "body_type",
28
+ "cylinders",
29
+ "dealer_type",
30
+ "availability_status",
31
+ "options_packages",
32
+ "features",
33
+ "keywords",
34
+ "price",
35
+ "msrp",
36
+ "miles",
37
+ "days_on_market",
38
+ }
39
+
40
+ FacetSort: TypeAlias = Literal["count", "-count", "metric", "-metric"]
41
+
42
+ # Facets that return range histograms; all others are categorical (bucket lists).
43
+ RANGE_FACET_NAMES = {"price", "msrp", "miles", "days_on_market"}
44
+ CATEGORICAL_FACET_NAMES = FACET_NAMES - RANGE_FACET_NAMES
45
+
46
+
47
+ class FacetsFilter(ListingsFilterBase):
48
+ """Filter for GET /v1/facets. No pagination or projection."""
49
+
50
+ facets: list[str]
51
+ facet_value_limit: int | None = None
52
+ metric: str | None = None
53
+ sort: FacetSort = "-count"
54
+
55
+ @field_validator("facets")
56
+ @classmethod
57
+ def _validate_facets(cls, v: list[str]) -> list[str]:
58
+ unknown = set(v) - FACET_NAMES
59
+ if unknown:
60
+ raise ValueError(f"unknown facets: {unknown}")
61
+ return v
62
+
63
+ @field_validator("facet_value_limit")
64
+ @classmethod
65
+ def _facet_value_limit_max(cls, v: int | None) -> int | None:
66
+ if v is not None and v > 100:
67
+ raise ValueError("facet_value_limit maximum is 100")
68
+ return v
69
+
70
+ @model_validator(mode="after")
71
+ def _validate_metric_needs_categorical_facet(self) -> "FacetsFilter":
72
+ if self.metric is not None:
73
+ categorical = [f for f in self.facets if f in CATEGORICAL_FACET_NAMES]
74
+ if len(categorical) != 1:
75
+ raise ValueError(
76
+ "metric requires exactly one categorical facet; "
77
+ f"got {len(categorical)} ({categorical})"
78
+ )
79
+ return self
80
+
81
+ def to_params(self) -> dict[str, str]:
82
+ params = super().to_params()
83
+ params["facets"] = ",".join(self.facets)
84
+ if self.facet_value_limit is not None:
85
+ params["facet_value_limit"] = str(self.facet_value_limit)
86
+ if self.metric is not None:
87
+ params["metric"] = self.metric
88
+ params["sort"] = self.sort
89
+ return params
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Response models
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class FacetBucket(VisorResponseModel):
98
+ value: str
99
+ count: int | None = None
100
+ metric: float | None = None
101
+
102
+
103
+ class RangeBucket(VisorResponseModel):
104
+ min: float
105
+ max: float
106
+ count: int
107
+
108
+
109
+ class RangeFacet(VisorResponseModel):
110
+ buckets: list[RangeBucket]
111
+ interval: float
112
+ min: float
113
+ max: float
114
+
115
+
116
+ class FieldStats(VisorResponseModel):
117
+ min: float
118
+ max: float
119
+ count: int
120
+ missing: int
121
+ mean: float
122
+ median: float
123
+ stddev: float
124
+
125
+
126
+ class FacetsData(VisorResponseModel):
127
+ total: int
128
+ facets: dict[str, list[FacetBucket]] = Field(default_factory=dict)
129
+ range_facets: dict[str, RangeFacet] = Field(default_factory=dict)
130
+ stats: dict[str, FieldStats] = Field(default_factory=dict)
131
+
132
+
133
+ class FacetsMeta(VisorResponseModel):
134
+ facets: list[str]
135
+ metric: str
136
+ sort: str
137
+ minimum_metric_count: int
138
+
139
+
140
+ class FacetsResponse(VisorResponseModel):
141
+ data: FacetsData
142
+ meta: FacetsMeta