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 +68 -0
- vehiclesdb/_labels.py +60 -0
- vehiclesdb/_normalize.py +19 -0
- vehiclesdb/_resolution.py +284 -0
- vehiclesdb/client.py +900 -0
- vehiclesdb/errors.py +114 -0
- vehiclesdb/models.py +230 -0
- vehiclesdb/py.typed +0 -0
- vehiclesdb-0.2.0.dist-info/METADATA +406 -0
- vehiclesdb-0.2.0.dist-info/RECORD +14 -0
- vehiclesdb-0.2.0.dist-info/WHEEL +5 -0
- vehiclesdb-0.2.0.dist-info/licenses/LICENSE +21 -0
- vehiclesdb-0.2.0.dist-info/licenses/NOTICE +16 -0
- vehiclesdb-0.2.0.dist-info/top_level.txt +1 -0
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"
|
vehiclesdb/_normalize.py
ADDED
|
@@ -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)
|