33import json
44import os
55
6- import requests
76from flask import (Flask , g , make_response , request , send_from_directory ,
87 url_for )
98from werkzeug .middleware .proxy_fix import ProxyFix
109
10+ from provider_dummy import DummyProvider # noqa: F401
11+ from provider_partstack import Partstack
12+
1113app = Flask (__name__ )
1214app .wsgi_app = ProxyFix (app .wsgi_app , x_proto = 1 , x_host = 1 )
1315
1416PARTS_MAX_COUNT = 10
1517PARTS_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
7420def _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' ])
26251def 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' ])
0 commit comments