Skip to content

Commit 2ebe830

Browse files
committed
Factor out provider API access into separate class
1 parent a74c47c commit 2ebe830

File tree

5 files changed

+309
-262
lines changed

5 files changed

+309
-262
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ RUN apk add --no-cache \
1212
py3-requests-pyc
1313

1414
# Copy files.
15-
COPY app.py app/
15+
COPY *.py app/
1616
COPY static/ app/static/
1717
WORKDIR app
1818

app.py

Lines changed: 26 additions & 261 deletions
Original file line numberDiff line numberDiff line change
@@ -3,72 +3,18 @@
33
import json
44
import os
55

6-
import requests
76
from flask import (Flask, g, make_response, request, send_from_directory,
87
url_for)
98
from werkzeug.middleware.proxy_fix import ProxyFix
109

10+
from provider_dummy import DummyProvider # noqa: F401
11+
from provider_partstack import Partstack
12+
1113
app = Flask(__name__)
1214
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
1315

1416
PARTS_MAX_COUNT = 10
1517
PARTS_QUERY_TIMEOUT = 8.0
16-
PARTS_QUERY_FRAGMENT = """
17-
fragment f on Stock {
18-
products {
19-
basic {
20-
manufacturer
21-
mfgpartno
22-
status
23-
}
24-
url
25-
imageUrl
26-
datasheetUrl
27-
}
28-
summary {
29-
inStockInventory
30-
medianPrice
31-
suppliersInStock
32-
}
33-
}
34-
"""
35-
PARTS_QUERY_STATUS_MAP = {
36-
'active': 'Active',
37-
'active-unconfirmed': 'Active',
38-
'nrfnd': 'NRND',
39-
'eol': 'Obsolete',
40-
'obsolete': 'Obsolete',
41-
'discontinued': 'Obsolete',
42-
'transferred': 'Obsolete',
43-
'contact mfr': None, # Not supported, but here to avoid warning.
44-
}
45-
MANUFACTURER_REPLACEMENTS = {
46-
'ä': 'ae',
47-
'ö': 'oe',
48-
'ü': 'ue',
49-
'texas instruments': 'ti',
50-
'stmicroelectronics': 'st',
51-
}
52-
MANUFACTURER_REMOVALS = set([
53-
'contact',
54-
'devices',
55-
'electronics',
56-
'inc.',
57-
'inc',
58-
'incorporated',
59-
'integrated',
60-
'international',
61-
'limited',
62-
'ltd.',
63-
'ltd',
64-
'microelectronics',
65-
'semiconductor',
66-
'semiconductors',
67-
'solutions',
68-
'systems',
69-
'technology',
70-
'usa',
71-
])
7218

7319

7420
def _get_config(key, fallback=None):
@@ -101,171 +47,15 @@ def _write_status(key_values):
10147
app.logger.critical(str(e))
10248

10349

104-
def _build_headers():
105-
return {
106-
'Content-Type': 'application/json',
107-
'Accept': 'application/json, multipart/mixed',
108-
'Authorization': 'Bearer {}'.format(_get_config('parts_query_token')),
109-
}
110-
111-
112-
def _build_request(parts):
113-
args = []
114-
queries = []
115-
variables = {}
116-
for i in range(len(parts)):
117-
args.append('$mpn{}:String!'.format(i))
118-
queries.append('q{}:findStocks(mfgpartno:$mpn{}){{...f}}'.format(i, i))
119-
variables['mpn{}'.format(i)] = parts[i]['mpn']
120-
query = 'query Stocks({}) {{\n{}\n}}'.format(
121-
','.join(args),
122-
'\n'.join(queries)
123-
) + PARTS_QUERY_FRAGMENT
124-
return dict(query=query, variables=variables)
125-
126-
127-
def _get_basic_value(product, key):
128-
if type(product) is dict:
129-
basic = product.get('basic')
130-
if type(basic) is dict:
131-
value = basic.get(key)
132-
if type(value) is str:
133-
return value
134-
return ''
135-
136-
137-
def _normalize_manufacturer(mfr):
138-
mfr = mfr.lower()
139-
for old, new in MANUFACTURER_REPLACEMENTS.items():
140-
mfr = mfr.replace(old, new)
141-
terms = [s for s in mfr.split(' ') if s not in MANUFACTURER_REMOVALS]
142-
return ' '.join(terms)
143-
144-
145-
def _calc_product_match_score(p, mpn_n, mfr_n):
146-
score = 0
147-
148-
status_p = PARTS_QUERY_STATUS_MAP.get(_get_basic_value(p, 'status'))
149-
if status_p == 'Active':
150-
score += 200
151-
elif status_p == 'NRND':
152-
score += 100
153-
154-
mpn_p = _get_basic_value(p, 'mfgpartno').lower()
155-
if mpn_p == mpn_n:
156-
score += 20 # MPN matches exactly.
157-
elif mpn_p.replace(' ', '') == mpn_n.replace(' ', ''):
158-
score += 10 # MPN matches when ignoring whitespaces.
159-
else:
160-
return 0 # MPN does not match!
161-
162-
mfr_p = _normalize_manufacturer(_get_basic_value(p, 'manufacturer'))
163-
if mfr_p == mfr_n:
164-
score += 4 # Manufacturer matches exactly.
165-
elif mfr_n in mfr_p:
166-
score += 3 # Manufacturer matches partially.
167-
elif mfr_n.replace(' ', '') in mfr_p.replace(' ', ''):
168-
score += 2 # Manufacturer matches partially when ignoring whitespaces.
169-
elif mfr_n.split(' ')[0] in mfr_p:
170-
score += 1 # The first term of the manufacturer matches.
171-
else:
172-
return 0 # Manufacturer does not match!
173-
174-
return score
175-
176-
177-
def _get_product(data, mpn, manufacturer):
178-
products = (data.get('products') or [])
179-
for p in products:
180-
p['_score'] = _calc_product_match_score(
181-
p, mpn.lower(), _normalize_manufacturer(manufacturer))
182-
products = sorted([p for p in products if p['_score'] > 0],
183-
key=lambda p: p['_score'], reverse=True)
184-
return products[0] if len(products) else None
185-
186-
187-
def _add_pricing_url(out, data):
188-
value = data.get('url')
189-
if value is not None:
190-
out['pricing_url'] = value
191-
192-
193-
def _add_image_url(out, data):
194-
value = data.get('imageUrl')
195-
if value is not None:
196-
out['picture_url'] = value
197-
198-
199-
def _add_status(out, data):
200-
status = data.get('status') or ''
201-
status_n = status.lower()
202-
value = PARTS_QUERY_STATUS_MAP.get(status_n.lower())
203-
if value is not None:
204-
out['status'] = value
205-
elif len(status_n) and (status_n not in PARTS_QUERY_STATUS_MAP):
206-
app.logger.warning('Unknown part lifecycle status: {}'.format(status))
207-
208-
209-
def _stock_to_availability(stock):
210-
if stock > 100000:
211-
return 10 # Very Good
212-
elif stock > 5000:
213-
return 5 # Good
214-
elif stock > 200:
215-
return 0 # Normal
216-
elif stock > 0:
217-
return -5 # Bad
218-
else:
219-
return -10 # Very Bad
220-
221-
222-
def _suppliers_to_availability(suppliers):
223-
if suppliers > 30:
224-
return 10 # Very Good
225-
elif suppliers > 9:
226-
return 5 # Good
227-
elif suppliers > 1:
228-
return 0 # Normal
229-
elif suppliers > 0:
230-
return -5 # Bad
231-
else:
232-
return -10 # Very Bad
233-
234-
235-
def _add_availability(out, data):
236-
stock = data.get('inStockInventory')
237-
suppliers = data.get('suppliersInStock')
238-
values = []
239-
if type(stock) is int:
240-
values.append(_stock_to_availability(stock))
241-
if type(suppliers) is int:
242-
values.append(_suppliers_to_availability(suppliers))
243-
if len(values):
244-
out['availability'] = min(values)
245-
246-
247-
def _add_prices(out, summary):
248-
value = summary.get('medianPrice')
249-
if type(value) in [float, int]:
250-
out['prices'] = [dict(quantity=1, price=float(value))]
251-
252-
253-
def _add_resources(out, data):
254-
value = data.get('datasheetUrl')
255-
if value is not None:
256-
out['resources'] = [
257-
dict(name="Datasheet", mediatype="application/pdf", url=value),
258-
]
259-
260-
26150
@app.route('/api/v1/parts', methods=['GET'])
26251
def parts():
26352
enabled = _get_config('parts_operational', False)
53+
provider = Partstack
26454
response = make_response(dict(
265-
provider_name='Partstack',
266-
provider_url='https://partstack.com',
55+
provider_name=provider.NAME,
56+
provider_url=provider.URL,
26757
provider_logo_url=url_for('parts_static',
268-
filename='parts-provider-partstack.png',
58+
filename=provider.LOGO_FILENAME,
26959
_external=True),
27060
info_url='https://api.librepcb.org/api',
27161
query_url=url_for('parts_query', _external=True) if enabled else None,
@@ -280,51 +70,26 @@ def parts_query():
28070
# Get requested parts.
28171
payload = request.get_json()
28272
parts = payload['parts'][:PARTS_MAX_COUNT]
73+
parts = [dict(mpn=p['mpn'], manufacturer=p['manufacturer']) for p in parts]
28374

284-
# Query parts from information provider.
285-
query_response = requests.post(
286-
_get_config('parts_query_url'),
287-
headers=_build_headers(),
288-
json=_build_request(parts),
289-
timeout=PARTS_QUERY_TIMEOUT,
290-
)
291-
query_json = query_response.json()
292-
data = query_json.get('data') or {}
293-
errors = query_json.get('errors') or []
294-
if (len(data) == 0) and (type(query_json.get('message')) is str):
295-
errors.append(query_json['message'])
296-
for error in errors:
297-
app.logger.warning("GraphQL Error: " + str(error))
298-
299-
# Handle quota limit.
300-
next_access_time = query_json.get('nextAccessTime')
301-
if (len(data) == 0) and (next_access_time is not None):
302-
app.logger.warning("Quota limit: " + str(next_access_time))
303-
_write_status(dict(next_access_time=next_access_time))
304-
305-
# Convert query response data and return it to the client.
306-
tx = dict(parts=[])
307-
for i in range(len(parts)):
308-
mpn = parts[i]['mpn']
309-
manufacturer = parts[i]['manufacturer']
310-
part_data = data.get('q' + str(i)) or {}
311-
product = _get_product(part_data, mpn, manufacturer)
312-
part = dict(
313-
mpn=mpn,
314-
manufacturer=manufacturer,
315-
results=0 if product is None else 1,
316-
)
317-
if product is not None:
318-
basic = product.get('basic') or {}
319-
summary = part_data.get('summary') or {}
320-
_add_pricing_url(part, product)
321-
_add_image_url(part, product)
322-
_add_status(part, basic)
323-
_add_availability(part, summary)
324-
_add_prices(part, summary)
325-
_add_resources(part, product)
326-
tx['parts'].append(part)
327-
return tx
75+
# Fetch parts from provider.
76+
status = dict()
77+
provider = Partstack(_get_config('parts_query_url'),
78+
_get_config('parts_query_token'),
79+
PARTS_QUERY_TIMEOUT, app.logger)
80+
provider.fetch(parts, status)
81+
82+
# Handle status changes.
83+
if len(status):
84+
_write_status(status)
85+
86+
# Complete parts which were not found.
87+
for part in parts:
88+
if 'results' not in part:
89+
part['results'] = 0
90+
91+
# Return response.
92+
return dict(parts=parts)
32893

32994

33095
@app.route('/api/v1/parts/static/<filename>', methods=['GET'])

demo-request.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
{
44
"mpn": "1N4148",
55
"manufacturer": "Vishay"
6+
},
7+
{
8+
"mpn": "NE555P",
9+
"manufacturer": "Texas Instruments"
610
}
711
]
812
}

provider_dummy.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# -*- coding: utf-8 -*-
2+
3+
4+
class DummyProvider:
5+
ID = 'dummy'
6+
7+
def __init__(self):
8+
pass
9+
10+
def fetch(self, parts, status):
11+
for i in range(len(parts)):
12+
part = parts[i]
13+
if 'results' not in part:
14+
self._fetch_part(part, i)
15+
16+
def _fetch_part(self, part, i):
17+
if i % 2:
18+
part['results'] = 1
19+
part['status'] = 'Active'
20+
part['prices'] = [dict(quantity=1, price=13.37)]

0 commit comments

Comments
 (0)