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/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
|
visor/models/dealers.py
ADDED
|
@@ -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
|