/
api_partinfo_kitspace.py
405 lines (368 loc) · 19.6 KB
/
api_partinfo_kitspace.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# -*- coding: utf-8 -*-
# MIT license
#
# Copyright (C) 2018 by XESS Corporation / Max Maisel / Hildo Guillardi Júnior
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# ******************************************************************************
# IMPORTANT: This is no longer an usable API. Is here just for testing purposes.
# ******************************************************************************
# Author information.
__author__ = 'Hildo Guillardi Júnior'
__webpage__ = 'https://github.com/hildogjr/'
__company__ = 'University of Campinas - Brazil'
# Libraries.
import json
import requests
import re
import sys
import os
import copy
import pprint
from collections import OrderedDict
if sys.version_info[0] < 3:
from urllib import quote_plus
else:
from urllib.parse import quote_plus
# KiCost definitions.
from .. import KiCostError, DistData, DEFAULT_CURRENCY, ERR_SCRAPE, W_NOINFO, NO_PRICE
# Distributors definitions.
from .distributor import distributor_class, QueryCache
from .log__ import debug_overview, debug_obsessive, warning
# Uncomment for debug
# Use `debug('x + 1')` for instance.
# def debug(expression):
# frame = sys._getframe(1)
# distributor_class.logger.info(expression, '=', repr(eval(expression, frame.f_globals, frame.f_locals)))
MAX_PARTS_PER_QUERY = 20 # Maximum number of parts in a single query.
# Information to return from PartInfo KitSpace server.
QUERY_AVAIABLE_CURRENCIES = ['GBP', 'EUR', 'USD']
# DEFAULT_CURRENCY
QUERY_ANSWER = '''
mpn{manufacturer, part},
datasheet,
description,
specs{key, value},
offers(from: {DISTRIBUTORS}){
product_url,
sku {vendor, part},
description,
moq,
in_stock_quantity,
prices{''' + ','.join(QUERY_AVAIABLE_CURRENCIES) + '''}
}
'''
# Informations not used: type,specs{key, name, value},image {url, credit_string, credit_url},stock_location
QUERY_ANSWER = re.sub(r'[\s\n]', '', QUERY_ANSWER)
QUERY_PART = 'query ($input: MpnInput!) { part(mpn: $input) {' + QUERY_ANSWER + '} }'
QUERY_MATCH = 'query ($input: [MpnOrSku]!){ match(parts: $input) {' + QUERY_ANSWER + '} }'
QUERY_SEARCH = 'query ($input: String!){ search(term: $input) {' + QUERY_ANSWER + '} }'
QUERY_URL = 'https://dev-partinfo.kitspace.org/graphql'
__all__ = ['api_partinfo_kitspace']
class QueryStruct(object):
def __init__(self, query, part, for_dists):
self.query = query # Each part reference query.
self.part = part # Pointer to the part.
self.for_dists = for_dists # Used the stock code mention for disambiguation, it is used `None` for the "manf#".
self.result = None
self.loaded = False
class api_partinfo_kitspace(distributor_class):
name = 'KitSpace'
type = 'api'
enabled = False
url = 'https://kitspace.org/' # Web site API information.
api_distributors = ['digikey', 'farnell', 'mouser', 'newark', 'rs', 'arrow', 'tme', 'lcsc']
DIST_TRANSLATION = { # Distributor translation.
'Digikey': 'digikey',
'Farnell': 'farnell',
'Mouser': 'mouser',
'Newark': 'newark',
'RS': 'rs',
'TME': 'tme',
'Arrow Electronics': 'arrow',
'LCSC': 'lcsc',
}
# Dict to translate KiCost field names into KitSpace distributor names
KICOST2KITSPACE_DIST = {v: k for k, v in DIST_TRANSLATION.items()}
cache = None
env_prefix = 'KITSPACE'
env_ops = {}
@staticmethod
def configure(ops):
cache_ttl = 7
cache_path = None
for k, v in ops.items():
if k == 'enable':
api_partinfo_kitspace.enabled = v
elif k == 'cache_ttl':
cache_ttl = v
elif k == 'cache_path':
cache_path = v
api_partinfo_kitspace.cache = QueryCache(cache_path, cache_ttl)
debug_obsessive('KitSpace API configured to enabled {}'.format(api_partinfo_kitspace.enabled))
@staticmethod
def query(query_parts, distributors, query_type=QUERY_MATCH):
'''Send query to server and return results.'''
distributors = [api_partinfo_kitspace.KICOST2KITSPACE_DIST[d] for d in distributors]
# Allow changing the URL for debug purposes
try:
url = os.environ['KICOST_KITSPACE_URL']
except KeyError:
url = QUERY_URL
# Sort the distributors to create a reproducible query
query_type = re.sub(r'\{DISTRIBUTORS\}', '["' + '","'.join(sorted(distributors)) + '"]', query_type)
# r = requests.post(url, {"query": QUERY_SEARCH, "variables": variables}) #TODO future use for ISSUE #17
variables = '{"input":[' + ','.join(query_parts) + ']}'
# Remove all spaces, even inside the manf#
# SET comment: this is how the code always worked. Octopart (used by KitSpace) ignores spaces inside manf# codes.
variables = variables.replace(' ', '')
# Do the query using POST
data = 'query={}&variables={}'.format(quote_plus(query_type), quote_plus(variables))
distributor_class.log_request(url, data)
data = OrderedDict()
data["query"] = query_type
data["variables"] = variables
response = requests.post(url, data)
response.encoding = 'UTF-8'
distributor_class.log_response(response)
if response.status_code == requests.codes['ok']: # 200
results = json.loads(response.text)
return results
elif response.status_code == requests.codes['not_found']: # 404
raise KiCostError('Kitspace server not found check your internet connection.', ERR_SCRAPE)
elif response.status_code == requests.codes['request_timeout']: # 408
raise KiCostError('KitSpace is not responding.', ERR_SCRAPE)
elif response.status_code == requests.codes['bad_request']: # 400
raise KiCostError('Bad request to Kitspace server probably due to an incorrect string '
'format check your `manf#` codes and contact the suport team.', ERR_SCRAPE)
elif response.status_code == requests.codes['gateway_timeout']: # 504
raise KiCostError('One of the internal Kitspace services may experiencing problems.'
' Most probably is saturated, please consider using other API.', ERR_SCRAPE)
else:
raise KiCostError('Kitspace error: ' + str(response.status_code), ERR_SCRAPE)
@staticmethod
def get_spec(data, item, default=None):
'''Get the value of `value` field of a dictionary if the `name` field identifier.
Used to get information from the JSON response.'''
for d in data['specs']:
if d['key'] == item:
value = d['value']
return value if value is not None else default
return default
@staticmethod
def query2name(query):
''' Finds the prefix and name for a query '''
q = json.loads(query)
mpn = q.get('mpn', None)
if mpn is not None:
prefix = 'mpn'
name = mpn['part']
else:
sku = q.get('sku', None)
if sku is not None:
prefix = 'sku'
name = sku['vendor'] + '_' + sku['part']
return prefix, name
@staticmethod
def get_part_info(queries, distributors):
'''Query PartInfo for quantity/price info.
`distributors` is the list of all distributors we want, in general. '''
only_query = [q.query for q in queries]
results = api_partinfo_kitspace.query(only_query, distributors)
for i, r in enumerate(results['data']['match']):
queries[i].result = r
# Solve the prefix and name
prefix, name = api_partinfo_kitspace.query2name(queries[i].query)
api_partinfo_kitspace.cache.save_results(prefix, name, r)
@staticmethod
def fill_part_info(queries, distributors, currency, solved):
''' Place the results into the parts list. '''
# Translate from PartInfo distributor names to the names used internally by kicost.
dist_xlate = api_partinfo_kitspace.DIST_TRANSLATION
# Loop through the response to the query and enter info into the parts list.
for q in queries:
# Unpack the structure
part_query = q.query
part = q.part
dist_want = q.for_dists
result = q.result
# Process it
if not result:
warning(W_NOINFO, 'No information found for parts \'{}\' query `{}`'.format(part.refs, str(part_query)))
continue
# Get the information of the part.
part.datasheet = result.get('datasheet')
part.lifecycle = api_partinfo_kitspace.get_spec(result, 'lifecycle_status', 'active').lower()
# Misc data collected, currently not used inside KiCost
part.update_specs({sp['key']: (sp['key'], sp['value']) for sp in result['specs'] if sp['value']})
# Loop through the offers from various dists for this particular part.
for offer in result['offers']:
# Get the distributor who made the offer and add their
# price/qty info to the parts list if its one of the accepted distributors.
dist = dist_xlate.get(offer['sku']['vendor'], '')
if dist not in dist_want:
# Not interested in this distributor
continue
# Get the DistData for this distributor
dd = part.dd.get(dist, DistData())
# This will happen if there are not enough entries in the price/qty list.
# As a stop-gap measure, just assign infinity to the part increment.
# A better alternative may be to examine the packaging field of the offer.
part_qty_increment = float("inf")
# Get pricing information from this distributor.
dist_currency = {cur: pri for cur, pri in offer['prices'].items() if pri}
if not dist_currency:
# Some times the API returns minimum purchase 0 and a not valid `price_tiers`.
warning(NO_PRICE, 'No price information found for parts \'{}\' query `{}`'.format(part.refs, str(part_query)))
else:
prices = None
# Get the price tiers prioritizing:
# 1) The asked currency by KiCost user;
# 2) The default currency given by `DEFAULT_CURRENCY` in root `global_vars.py`;
# 3) The first not null tiers
if currency in dist_currency:
prices = dist_currency[currency]
dd.currency = currency
elif DEFAULT_CURRENCY in dist_currency:
prices = dist_currency[DEFAULT_CURRENCY]
dd.currency = DEFAULT_CURRENCY
else:
dd.currency, prices = next(iter(dist_currency.items()))
price_tiers = {qty: float(price) for qty, price in prices}
# Combine price lists for multiple offers from the same distributor
# to build a complete list of cut-tape and reeled components.
dd.price_tiers.update(price_tiers)
# Compute the quantity increment between the lowest two prices.
# This will be used to distinguish the cut-tape from the reeled components.
if len(price_tiers) > 1:
part_break_qtys = sorted(price_tiers.keys())
part_qty_increment = part_break_qtys[1] - part_break_qtys[0]
# Select the part SKU, web page, and available quantity.
# Each distributor can have different stock codes for the same part in different
# quantities / delivery package styles: cut-tape, reel, ...
# Therefore we select and overwrite a previous selection if one of the
# following conditions is met:
# 1. We don't have a selection for this part from this distributor yet.
# 2. The MOQ is smaller than for the current selection.
# 3. The part_qty_increment for this offer smaller than that of the existing selection.
# (we prefer cut-tape style packaging over reels)
# 4. For DigiKey, we can't use part_qty_increment to distinguish between
# reel and cut-tape, so we need to look at the actual DigiKey part number.
# This procedure is made by the definition `distributors_info[dist]['ignore_cat#_re']`
# at the distributor profile.
dist_part_num = offer.get('sku', '').get('part', '')
qty_avail = dd.qty_avail
in_stock_quantity = offer.get('in_stock_quantity')
if not qty_avail or (in_stock_quantity and qty_avail < in_stock_quantity):
# Keeps the information of more availability.
dd.qty_avail = in_stock_quantity # In stock.
ign_stock_code = distributor_class.get_distributor_info(dist).ignore_cat
valid_part = not (ign_stock_code and re.match(ign_stock_code, dist_part_num))
# debug('dd.part_num') # Uncomment to debug
# debug('dd.qty_increment') # Uncomment to debug
moq = offer.get('moq')
if (valid_part and
(not dd.part_num or
(dd.qty_increment is None or part_qty_increment < dd.qty_increment) or
(not dd.moq or (moq and dd.moq > moq)))):
# Save the link, stock code, ... of the page for minimum purchase.
dd.moq = moq # Minimum order qty.
dd.url = offer.get('product_url', '') # Page to purchase the minimum quantity.
dd.part_num = dist_part_num
dd.qty_increment = part_qty_increment
# Update the DistData for this distributor
part.dd[dist] = dd
# We have data for this distributor
solved.add(dist)
@staticmethod
def query_part_info(parts, distributors, currency):
'''Fill-in the parts with price/qty/etc info from KitSpace.'''
debug_overview('# Getting part data from KitSpace...')
# Use just the distributors avaliable in this API.
# Note: The user can use --exclude and define it with fields.
distributors = [d for d in distributors if distributor_class.get_distributor_info(d).is_web()
and d in api_partinfo_kitspace.api_distributors]
FIELDS_CAT = sorted([d + '#' for d in distributors])
# Create queries to get part price/quantities from PartInfo.
queries = []
# Translate from PartInfo distributor names to the names used internally by kicost.
available_distributors = set(api_partinfo_kitspace.api_distributors)
for part in parts:
# Create a PartInfo query using the manufacturer's part number or the distributor's SKU.
part_dist_use_manfpn = copy.copy(distributors)
# Create queries using the distributor SKU
# Check if that part have stock code that is accepted to use by this module (API).
# KiCost will prioritize these codes under "manf#" that will be used for get
# information for the part hat were not filled with the distributor stock code. So
# this is checked after the 'manf#' buv code.
found_codes_for_all_dists = True
for d in FIELDS_CAT:
part_stock = part.fields.get(d)
if part_stock:
part_catalogue_code_dist = d[:-1]
if part_catalogue_code_dist in available_distributors:
part_code_dist = api_partinfo_kitspace.KICOST2KITSPACE_DIST[part_catalogue_code_dist]
query_text = '{"sku":{"vendor":"' + part_code_dist + '","part":"' + part_stock + '"}}'
queries.append(QueryStruct(query_text, part, [part_catalogue_code_dist]))
part_dist_use_manfpn.remove(part_catalogue_code_dist)
else:
found_codes_for_all_dists = False
# Create a query using the manufacturer P/N
part_manf = part.fields.get('manf', '')
part_code = part.fields.get('manf#')
if part_code and not found_codes_for_all_dists:
# Not all distributors has code, add a query for the manufaturer P/N
query_text = '{"mpn":{"manufacturer":"' + part_manf + '","part":"' + part_code + '"}}'
# List of distributors without an specific part number
queries.append(QueryStruct(query_text, part, part_dist_use_manfpn))
n_queries = len(queries)
debug_overview('Queries {}'.format(n_queries))
if not n_queries:
return
# Try to solve the queries from the cache
unsolved = []
for query in queries:
# Solve the prefix and name
prefix, name = api_partinfo_kitspace.query2name(query.query)
# Look in the cache
query.result, query.loaded = api_partinfo_kitspace.cache.load_results(prefix, name)
if not query.loaded:
unsolved.append(query)
else:
debug_obsessive('Data from cache: '+pprint.pformat(query.result))
# Solve the rest from the site
n_unsolved = len(unsolved)
debug_overview('Cached entries {} (of {})'.format(n_queries-n_unsolved, n_queries))
if n_unsolved:
# Setup progress bar to track progress of server queries.
progress = distributor_class.progress(n_unsolved, distributor_class.logger)
# Slice the queries into batches of the largest allowed size and gather
# the part data for each batch.
for i in range(0, n_unsolved, MAX_PARTS_PER_QUERY):
slc = slice(i, i+MAX_PARTS_PER_QUERY)
api_partinfo_kitspace.get_part_info(unsolved[slc], distributors)
progress.update(len(unsolved[slc]))
# Done with the scraping progress bar so delete it or else we get an
# error when the program terminates.
progress.close()
# Transfer the results
solved_dist = set()
api_partinfo_kitspace.fill_part_info(queries, distributors, currency, solved_dist)
return solved_dist
distributor_class.register(api_partinfo_kitspace, 50)