vehiclesdb 0.2.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.
vehiclesdb/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """vehiclesdb — vehicle makes, models and canonical ids, from the open dataset.
2
+
3
+ from vehiclesdb import VehiclesDB
4
+
5
+ db = VehiclesDB()
6
+ db.resolve("VW Golf 1.9 TDI", kind="car").id # "car/volkswagen/golf"
7
+
8
+ The product is the id: `kind/make_slug/model_slug`. Store that.
9
+
10
+ **Attribution is a licence condition.** The dataset is CC-BY 4.0 and §3(a)
11
+ requires visible credit — `db.attribution` returns the notice so you can render
12
+ it without copying prose.
13
+
14
+ Zero required dependencies: everything runs on `urllib.request` + `json`.
15
+ `httpx` is optional and is never imported unless you ask for it
16
+ (`vehiclesdb.httpx_transport`).
17
+ """
18
+
19
+ from .client import (
20
+ ATTRIBUTION_HTML,
21
+ ATTRIBUTION_TEXT,
22
+ DATASET_BASE_URL_TEMPLATE,
23
+ DEFAULT_API_BASE_URL,
24
+ KIND_PRECEDENCE,
25
+ VehiclesDB,
26
+ __version__,
27
+ httpx_transport,
28
+ urllib_transport,
29
+ )
30
+ from .errors import (
31
+ DatasetError,
32
+ MissingApiKeyError,
33
+ TransportError,
34
+ VehiclesDBApiError,
35
+ VehiclesDBAuthError,
36
+ VehiclesDBError,
37
+ VehiclesDBNotFoundError,
38
+ VehiclesDBPaymentRequiredError,
39
+ VehiclesDBRateLimitError,
40
+ VehiclesDBTimeoutError,
41
+ )
42
+ from .models import MATCH_CONFIDENCE, Make, Model, Resolution
43
+
44
+ __all__ = [
45
+ "VehiclesDB",
46
+ "Make",
47
+ "Model",
48
+ "Resolution",
49
+ "MATCH_CONFIDENCE",
50
+ "VehiclesDBError",
51
+ "MissingApiKeyError",
52
+ "VehiclesDBApiError",
53
+ "VehiclesDBAuthError",
54
+ "VehiclesDBPaymentRequiredError",
55
+ "VehiclesDBNotFoundError",
56
+ "VehiclesDBRateLimitError",
57
+ "VehiclesDBTimeoutError",
58
+ "KIND_PRECEDENCE",
59
+ "DatasetError",
60
+ "TransportError",
61
+ "urllib_transport",
62
+ "httpx_transport",
63
+ "ATTRIBUTION_TEXT",
64
+ "ATTRIBUTION_HTML",
65
+ "DATASET_BASE_URL_TEMPLATE",
66
+ "DEFAULT_API_BASE_URL",
67
+ "__version__",
68
+ ]
vehiclesdb/_labels.py ADDED
@@ -0,0 +1,60 @@
1
+ """Display labels in English and Spanish: the canonical colour palette, kinds
2
+ and body types. The SAME strings as the JS SDK and the gem `vehicles` (0.9.2):
3
+ the slug is what you store, the label is what you show."""
4
+
5
+ LOCALES = ("en", "es")
6
+
7
+ # (slug, hex, {locale: name}) — frequency-ordered, as the gem's Colors::ALL.
8
+ COLORS = (
9
+ ("white", "#F4F4F4", {"en": "White", "es": "Blanco"}),
10
+ ("black", "#1B1B1B", {"en": "Black", "es": "Negro"}),
11
+ ("grey", "#8A8D90", {"en": "Grey", "es": "Gris"}),
12
+ ("silver", "#C7CCD1", {"en": "Silver", "es": "Plata"}),
13
+ ("blue", "#27408B", {"en": "Blue", "es": "Azul"}),
14
+ ("red", "#B81D24", {"en": "Red", "es": "Rojo"}),
15
+ ("green", "#2E6B3E", {"en": "Green", "es": "Verde"}),
16
+ ("brown", "#5A3A22", {"en": "Brown", "es": "Marrón"}),
17
+ ("beige", "#D9C6A5", {"en": "Beige", "es": "Beige"}),
18
+ ("orange", "#E8731A", {"en": "Orange", "es": "Naranja"}),
19
+ ("yellow", "#F2C200", {"en": "Yellow", "es": "Amarillo"}),
20
+ ("gold", "#C8A951", {"en": "Gold", "es": "Dorado"}),
21
+ ("purple", "#6B3FA0", {"en": "Purple", "es": "Morado"}),
22
+ ("other", "#9AA0A6", {"en": "Other", "es": "Otro"}),
23
+ )
24
+
25
+ # Normalized spellings that land on a palette slug. English ones mirror the
26
+ # gem's Colors::SYNONYMS; Spanish ones cover gender and common variants.
27
+ COLOR_SYNONYMS = {
28
+ "gray": "grey", "silvery": "silver", "tan": "beige", "cream": "beige", "burgundy": "red", "navy": "blue",
29
+ "blanca": "white", "negra": "black", "plateado": "silver", "plateada": "silver", "gris": "grey",
30
+ "dorada": "gold", "amarilla": "yellow", "morada": "purple", "violeta": "purple", "granate": "red",
31
+ "azul marino": "blue", "crema": "beige", "otra": "other",
32
+ }
33
+
34
+ KIND_LABELS = {
35
+ "car": {"en": "Car", "es": "Coche"},
36
+ "motorcycle": {"en": "Motorcycle", "es": "Moto"},
37
+ "moped": {"en": "Moped", "es": "Ciclomotor"},
38
+ "van": {"en": "Van", "es": "Furgoneta"},
39
+ "truck": {"en": "Truck", "es": "Camión"},
40
+ "bus": {"en": "Bus", "es": "Autobús"},
41
+ }
42
+
43
+ BODY_TYPE_LABELS = {
44
+ "hatchback": {"en": "Hatchback", "es": "Compacto"},
45
+ "sedan": {"en": "Sedan", "es": "Berlina"},
46
+ "wagon": {"en": "Wagon", "es": "Familiar"},
47
+ "suv": {"en": "SUV", "es": "SUV"},
48
+ "mpv": {"en": "MPV", "es": "Monovolumen"},
49
+ "coupe": {"en": "Coupe", "es": "Cupé"},
50
+ "convertible": {"en": "Convertible", "es": "Descapotable"},
51
+ "roadster": {"en": "Roadster", "es": "Roadster"},
52
+ "pickup": {"en": "Pickup", "es": "Pick-up"},
53
+ "van": {"en": "Van", "es": "Furgoneta"},
54
+ }
55
+
56
+
57
+ def locale_of(locale):
58
+ """`es`, `es-ES`, `es_ES` -> `es`; anything unsupported -> `en`."""
59
+ key = str(locale or "en").strip().lower().replace("_", "-").split("-")[0]
60
+ return key if key in LOCALES else "en"
@@ -0,0 +1,19 @@
1
+ """The shared RESOLUTION.md §2 normalization, identical to the JS SDK."""
2
+ import re
3
+ import unicodedata
4
+
5
+
6
+ def fold(value):
7
+ if value is None:
8
+ return ''
9
+ text = ''.join(c for c in unicodedata.normalize('NFKD', str(value))
10
+ if unicodedata.category(c) != 'Mn')
11
+ return re.sub('[^a-z0-9]+', ' ', text.lower()).strip()
12
+
13
+
14
+ def slugify(value):
15
+ return fold(value).replace(' ', '-')
16
+
17
+
18
+ def tokens(value):
19
+ return value.split()
@@ -0,0 +1,284 @@
1
+ """Published resolution ladder; port of the Ruby ModelMatcher (RESOLUTION.md)."""
2
+ import re
3
+ from ._normalize import fold
4
+ from .models import Resolution, MATCH_CONFIDENCE
5
+
6
+ FILLERS = set('nuevo nueva new neu neue nouveau nouvelle nuovo nuova novo nova'.split())
7
+
8
+
9
+ def raw_key(s):
10
+ return str(s or '').strip().lower()
11
+
12
+
13
+ def eligible(raw):
14
+ s = str(raw or '').strip()
15
+ return all(not (re.search(r'[/_+\\]', token) or token.startswith('-') or token.endswith('-') or '--' in token or
16
+ re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)+', token) or
17
+ re.fullmatch(r'[A-Z0-9]+(?:-[A-Z0-9]+)+', token)) for token in s.split())
18
+
19
+
20
+ def distance(a, b, limit):
21
+ """Optimal string alignment, with adjacent transposition as one edit."""
22
+ prev2, prev = None, list(range(len(b) + 1))
23
+ for i, ch in enumerate(a, 1):
24
+ cur = [i]
25
+ for j, other in enumerate(b, 1):
26
+ n = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ch != other))
27
+ if prev2 is not None and j > 1 and ch == b[j - 2] and a[i - 2] == other:
28
+ n = min(n, prev2[j - 2] + 1)
29
+ cur.append(n)
30
+ if min(cur) > limit and min(prev) > limit:
31
+ return limit + 1
32
+ prev2, prev = prev, cur
33
+ return min(prev[-1], limit + 1)
34
+
35
+
36
+ def word_distance(query, key):
37
+ if len(query) < 4:
38
+ return None
39
+ limit = 2 if len(query) >= 6 else 1
40
+ if abs(len(query) - len(key)) > limit:
41
+ return None
42
+ words, others = query.split(), key.split()
43
+ if len(words) != len(others):
44
+ return None
45
+ total = 0
46
+ for a, b in zip(words, others):
47
+ if a == b:
48
+ continue
49
+ if a[0] != b[0] or re.search('[0-9]', a + b) or min(len(a), len(b)) < 4:
50
+ return None
51
+ budget = 2 if len(a) >= 9 else 1
52
+ d = distance(a, b, budget)
53
+ total += d
54
+ if d > budget or total > limit:
55
+ return None
56
+ return total or None
57
+
58
+
59
+ class Resolver:
60
+ def __init__(self, db):
61
+ self.db = db
62
+ self.scopes = {}
63
+ self.make_keys = []
64
+ for make in db._makes:
65
+ for s in [make.name, make.slug] + (make.aliases or []):
66
+ if fold(s):
67
+ self.make_keys.append((fold(s), make))
68
+ self.scopes[make.slug] = [
69
+ dict(model=m, name=fold(m.name), slug=fold(m.slug),
70
+ aliases=[fold(a) for a in m.aliases or [] if fold(a)],
71
+ forms=[fold(a) for a in m.forms or [] if fold(a)])
72
+ for m in db._models_by_make.get(make.slug, [])]
73
+
74
+ def pick(self, models):
75
+ from .client import pick_collision
76
+ return pick_collision(models)
77
+
78
+ def result(self, hits, method, via, ambiguous=False, confidence=None, selected=None):
79
+ hits = list({m.id: m for m in hits}.values())
80
+ model = None if ambiguous else (selected or self.pick(hits)) if hits else None
81
+ if model is None and not ambiguous:
82
+ return None
83
+ score = 0.0 if ambiguous else MATCH_CONFIDENCE[method] if confidence is None else confidence
84
+ return Resolution(id=model.id if model else None, kind=model.kind if model else None,
85
+ make=model.make_name if model else None, model=model.name if model else None,
86
+ method=method, confidence=score, via=list(via), candidates=hits,
87
+ ambiguous=ambiguous, record=model)
88
+
89
+ def make(self, raw, fuzzy=True):
90
+ q, key = fold(raw), raw_key(raw)
91
+ for m in self.db._makes:
92
+ if q and q in (fold(m.name), fold(m.slug)):
93
+ return dict(make=m, via=[], typo=False)
94
+ for m in self.db._makes:
95
+ if q and any(fold(f) == q for f in m.former_slugs):
96
+ return dict(make=m, via=['former_make_slug:%s→%s' % (q, m.slug)], typo=False)
97
+ for m in self.db._makes:
98
+ if any(fold(a) == q if q else key and raw_key(a) == key for a in m.aliases or []):
99
+ return dict(make=m, via=['make_alias:%s→%s' % (q, m.slug)] if q else [], typo=False)
100
+ if not fuzzy or not eligible(raw) or len(q) < 4:
101
+ return None
102
+ limit = 2 if len(q) >= 6 else 1
103
+ words = q.split()
104
+ best, hits = float('inf'), {}
105
+ for k, m in self.make_keys:
106
+ other = k.split()
107
+ if abs(len(q) - len(k)) > limit or len(other) < len(words):
108
+ continue
109
+ if len(other) == len(words):
110
+ if any(a[0] != b[0] for a, b in zip(words, other)):
111
+ continue
112
+ elif words[0][0] != other[0][0]:
113
+ continue
114
+ d = distance(q, k, limit)
115
+ if d > limit or d > best:
116
+ continue
117
+ if d < best:
118
+ best, hits = d, {}
119
+ hits[m.slug] = m
120
+ if len(hits) != 1:
121
+ return None
122
+ m = next(iter(hits.values()))
123
+ return dict(make=m, via=['make_typo:%s→%s' % (q, m.slug)], typo=True)
124
+
125
+ def scope(self, make, kind, country, kinds=None):
126
+ return [e for e in self.scopes[make.slug] if
127
+ (not kind or e['model'].kind == kind) and (kinds is None or e['model'].kind in kinds) and
128
+ (not country or raw_key(country) in e['model'].availability)]
129
+
130
+ def rungs(self, entries, query, via, cap):
131
+ sq = query.replace(' ', '')
132
+ hits = [e for e in entries if query in (e['name'], e['slug'])]
133
+ transformed, ambiguous = False, False
134
+ if not hits:
135
+ hits = [e for e in entries if e['name'].replace(' ', '') == sq]
136
+ transformed = True
137
+ ambiguous = len({e['model'].slug for e in hits}) > 1
138
+ if not hits and re.fullmatch(r'serie \w+', query):
139
+ series = re.sub(r'^serie (\w+)$', r'\1 series', query)
140
+ hits = [e for e in entries if series in (e['name'], e['slug'])]
141
+ if not hits:
142
+ hits = [e for e in entries if e['name'].replace(' ', '') == series.replace(' ', '')]
143
+ ambiguous = len({e['model'].slug for e in hits}) > 1
144
+ transformed = True
145
+ if hits:
146
+ models = [e['model'] for e in hits]
147
+ steps = ['fold:%s' % query] if ambiguous else ['fold:%s→%s' % (query, self.pick(models).slug)] if transformed else []
148
+ return self.result(models, 'fold' if ambiguous else cap or 'fold', via + steps, ambiguous)
149
+ for field, method in [('aliases', 'alias'), ('forms', 'form')]:
150
+ hits = [e for e in entries if query in e[field]]
151
+ if not hits:
152
+ hits = [e for e in entries if any(s.replace(' ', '') == sq for s in e[field])]
153
+ if not hits:
154
+ continue
155
+ models = [e['model'] for e in hits]
156
+ ambiguous = len({m.slug for m in models}) > 1
157
+ step = '%s:%s%s' % (method, query, '' if ambiguous else '→' + self.pick(models).slug)
158
+ return self.result(models, method if ambiguous else cap or method, via + [step], ambiguous)
159
+ return None
160
+
161
+ def ladder(self, resolved, raw, kind=None, country=None, heuristics=True, make_query='', kinds=None):
162
+ make, typo = resolved['make'], resolved['typo']
163
+ via = (['kind_hint:%s' % kind] if kind else []) + resolved['via']
164
+ entries = self.scope(make, kind, country, kinds)
165
+ query = fold(raw)
166
+ if not query:
167
+ hits = [e['model'] for e in entries if raw_key(raw) and any(raw_key(a) == raw_key(raw) for a in e['model'].aliases or [])]
168
+ if not hits:
169
+ return None
170
+ ambiguous = len({m.slug for m in hits}) > 1
171
+ return self.result(hits, 'fuzzy' if typo and not ambiguous else 'alias',
172
+ via + ['alias:%s%s' % (str(raw).strip(), '' if ambiguous else '→' + self.pick(hits).slug)], ambiguous)
173
+ exact = self.rungs(entries, query, via, 'fuzzy' if typo else None)
174
+ if exact is not None or not heuristics or not eligible(raw):
175
+ return exact
176
+ all_entries = self.scopes[make.slug]
177
+
178
+ def model_word(key):
179
+ return any(key in (e['name'], e['slug']) or key in e['aliases'] for e in all_entries)
180
+
181
+ names = [make.name, make.slug] + (make.aliases or []) + ([make_query] if not typo else [])
182
+ names += [n.replace('&', ' and ') for n in names if '&' in n]
183
+ echoes = sorted(dict.fromkeys(fold(n) for n in names if fold(n)), key=lambda n: -len(n.split()))
184
+ steps = []
185
+ while query:
186
+ head = query.split()[0]
187
+ if head in FILLERS and not model_word(head):
188
+ peel, step = head, 'filler:%s' % head
189
+ else:
190
+ peel = next((e for e in echoes if (query == e or query.startswith(e + ' ')) and not model_word(e)), None)
191
+ step = 'make_echo:%s' % peel
192
+ if not peel:
193
+ break
194
+ query = query[len(peel):].strip()
195
+ steps.append(step)
196
+ if not query:
197
+ return None
198
+ retried = self.rungs(entries, query, via + steps, 'fuzzy' if typo else 'cleaned')
199
+ if retried is not None:
200
+ return retried
201
+ if typo:
202
+ return None
203
+ best = {}
204
+ for e in entries:
205
+ for key in [e['name']] + e['aliases'] + e['forms']:
206
+ d = word_distance(query, key)
207
+ if d is None:
208
+ continue
209
+ old = best.get(e['model'].slug)
210
+ if old is None or d < old[0]:
211
+ best[e['model'].slug] = [d, key, [e['model']]]
212
+ elif d == old[0]:
213
+ old[2].append(e['model'])
214
+ ranked = sorted(best.values(), key=lambda r: r[0])
215
+ if not ranked:
216
+ return None
217
+ top = [r for r in ranked if r[0] == ranked[0][0]]
218
+ ambiguous = len(top) > 1
219
+ return self.result([m for r in top for m in r[2]], 'fuzzy',
220
+ via + steps + ['model_typo:%s%s' % (query, '' if ambiguous else '→' + top[0][1])], ambiguous)
221
+
222
+ def pair(self, make, model, kind=None, country=None, kinds=None):
223
+ resolved = self.make(make)
224
+ if resolved is None:
225
+ return None
226
+ return self.ladder(resolved, model, kind, country, make_query=make, kinds=kinds)
227
+
228
+ def text(self, raw, kind=None, country=None, kinds=None):
229
+ raw = str(raw or '').strip()
230
+ if not raw:
231
+ return None
232
+
233
+ def allowed(m):
234
+ return ((not kind or m.kind == kind) and (kinds is None or m.kind in kinds) and
235
+ (not country or raw_key(country) in m.availability))
236
+
237
+ hint = ['kind_hint:%s' % kind] if kind else []
238
+ def by_id(hits, method):
239
+ hits = [m for m in hits if (kinds is None or m.kind in kinds) and
240
+ (not country or raw_key(country) in m.availability)]
241
+ if not hits:
242
+ return None
243
+ via = hint + ['%s:%s' % (method, raw)]
244
+ if len({(m.make_id, m.slug) for m in hits}) > 1:
245
+ return self.result(hits, method, via, True)
246
+ preferred = [m for m in hits if kind and m.kind == kind]
247
+ return self.result(hits, method, via, selected=self.pick(preferred or hits))
248
+
249
+ exact = self.db._models_by_id.get(raw)
250
+ former = [m for m in self.db._former_ids.get(raw.lower(), []) if raw in (m.former_ids or [])]
251
+ id_result = by_id([exact] if exact is not None else [], 'exact') or by_id(former, 'former_id')
252
+ if id_result is not None:
253
+ return id_result
254
+ q = fold(raw)
255
+ if not q:
256
+ return None
257
+ tokens = q.split()
258
+ split = None
259
+ for fuzzy in ((False, True) if eligible(raw) else (False,)):
260
+ for i in range(len(tokens) - (1 if fuzzy else 0), 0, -1):
261
+ make_query, model = ' '.join(tokens[:i]), ' '.join(tokens[i:])
262
+ resolved = self.make(make_query, fuzzy)
263
+ if resolved is not None:
264
+ split = (resolved, model, make_query)
265
+ break
266
+ if split:
267
+ break
268
+ if split and split[1]:
269
+ found = self.rungs(self.scope(split[0]['make'], kind, country, kinds), split[1],
270
+ hint + split[0]['via'], 'fuzzy' if split[0]['typo'] else None)
271
+ if found is not None:
272
+ return found
273
+ hits = [m for m in self.db._alias_index.get(q, []) if allowed(m)]
274
+ if hits:
275
+ records = {(m.make_id, m.slug) for m in hits}
276
+ named = {(m.make_id, m.slug) for m in self.db._name_index.get(q, []) if fold(m.name) == q}
277
+ if not (named - records):
278
+ if len(records) > 1:
279
+ return self.result(hits, 'alias', hint + ['alias:%s' % q], True)
280
+ model = self.pick(hits)
281
+ return self.result(hits, 'alias', hint + ['alias:%s→%s/%s' % (q, model.make_id, model.slug)], confidence=.90)
282
+ if not split or not split[1] or not eligible(raw):
283
+ return None
284
+ return self.ladder(split[0], split[1], kind, country, True, split[2], kinds)