)
53 | etsy.findAllFeaturedListings()
54 | ```
55 |
56 | For endpoints that do require OAuth you must pass an `EtsyOAuthClient` to the `Etsy` constructor.
57 |
58 | ```python
59 | etsy_oauth = EtsyOAuthClient(client_key=api_key,
60 | client_secret=shared_secret,
61 | resource_owner_key=oauth_token,
62 | resource_owner_secret=oauth_token_secret)
63 | etsy = Etsy(etsy_oauth_client=etsy_oauth)
64 | ```
65 |
66 | The `EtsyOAuthClient` requires a client_key, client_secret, resource_owner_key, and resource_owner_secret to be constructed. The client_key and the client_secret are the keystring and shared secret given to you by etsy upon registering your app. The resource_owner_key and resource_owner_secret are the oauth_token and oauth_token_secret that must be retrieved by working through etsy's oauth workflow. See the "Obtaining Etsy OAuthCredentials" section to learn how to get the oauth_token and oauth_token_secret used by the EtsyOAuthClient.
67 |
68 | ## Obtaining Etsy OAuthCredentials
69 |
70 | The `EtsyOAuthHelper` exists to simplify the retrieval of the oauth_token and oauth_token_secret. The first step of the process will always be generating the login_url to which you will redirect the resource owner (user of your application). Usage is shown below.
71 |
72 | ```python
73 | # define permissions scopes as defined in the 'OAuth Authentication' section of the docs
74 | # https://www.etsy.com/developers/documentation/getting_started/oauth#section_permission_scopes
75 | permission_scopes = ['transactions_r', 'listings_r']
76 |
77 | # In this case, user will be redirected to page on etsy that shows the verification code.
78 | login_url, temp_oauth_token_secret = \
79 | EtsyOAuthHelper.get_request_url_and_token_secret(api_key, shared_secret, permission_scopes)
80 |
81 | # In this case, user will be redirected to the callback_url after telling etsy to allow access to their data.
82 | login_url, temp_oauth_token_secret = \
83 | EtsyOAuthHelper.get_request_url_and_token_secret(api_key, shared_secret, permission_scopes, callback_url)
84 |
85 | # Note,
86 | # login_url is the url to redirect the user to have them authenticate with etsy.
87 | # temp_oauth_token_secret is the secret used in the get_ouath_token methods to retrieve permanent oauth credentials.
88 | ```
89 |
90 | After your user has told Etsy they want to give you access to their data, the next step is to trade your temp_oauth_token and temp_oauth_token_secret for the permanent oauth_token and oauth_token_secret. There are two different paths for achieving this and which one you take depends on if you specified a callback_url in your request to `EtsyOAuthHelper.get_request_url_and_token_secret`.
91 |
92 | ### If a callback url was not specified
93 |
94 | If you did not specify a callback url, the user will be redirected to a page owned by etsy that displays the verification code. You need to pass this verification code along with the temp_oauth_token and temp_oauth_token_secret to the `EtsyOAuthHelper.get_oauth_token_via_verifier` method to retrieve the final tokens expected by the `EtsyOAuthClient`. This is done as shown below.
95 |
96 | ```python
97 | oauth_token, oauth_token_secret = \
98 | EtsyOAuthHelper.get_oauth_token_via_verifier(api_key, shared_secret, temp_oauth_token, temp_oauth_token_secret, verifier)
99 |
100 | # Note,
101 | # temp_oauth_token is part of the login_url generated by get_request_url_and_token_secret
102 | # temp_oauth_token_secret is returned by get_request_url_and_token_secret
103 | # verifier is the verification code on the screen etsy redirects the user to
104 | ```
105 |
106 | The oauth_token and oauth_token_secret obtained from this step are the tokens expected by the `EtsyOAuthClient`.
107 |
108 | It is important to note that temp_oauth_token is part of the login_url generated by get_request_url_and_token_secret. If you are going to use `EtsyOAuthHelper.get_oauth_token_via_verifier` you need to parse the temp_oauth_token from the login url before it can be passed as a parameter. This can be done as shown below.
109 |
110 | ```python
111 | import urllib.parse as urlparse
112 | from urllib.parse import parse_qs
113 |
114 | login_url, temp_oauth_token_secret = \
115 | EtsyOAuthHelper.get_request_url_and_token_secret(api_key, shared_secret, permission_scopes, auth_callback_url)
116 |
117 | query = urlparse.urlparse(login_url).query
118 | temp_oauth_token = parse_qs(query)['oauth_token'][0]
119 | ```
120 |
121 | ### If a callback url was specified
122 |
123 | If you specified a callback_url, etsy will redirect the user to that url after the user grants access to your application. Etsy will append the temp_oauth_token and the verifier as query strings to callback_url. You should pass the full url the the user was redirected to to `EtsyOAuthHelper.get_oauth_token_via_auth_url` as shown below.
124 |
125 | ```python
126 | oauth_token, oauth_token_secret = \
127 | EtsyOAuthHelper.get_oauth_token_via_auth_url(api_key, shared_secret, temp_oauth_token_secret, auth_url)
128 |
129 | # Note,
130 | # temp_oauth_token_secret is returned from get_request_url_and_token_secret
131 | # auth_url is the url the user was redirected to by etsy
132 | ```
133 |
134 | `EtsyOAuthHelper.get_oauth_token_via_auth_url` will obtain the temp_oauth_token and the verifier from the auth_url. The oauth_token and oauth_token_secret obtained from this step are the tokens expected by the `EtsyOAuthClient`.
135 |
136 |
137 | ## Configuration
138 |
139 | For convenience (and to avoid storing API keys in revision control
140 | systems), the package supports local configuration. You can manage
141 | your API keys in a file called $HOME/etsy/keys (or the equivalent on
142 | Windows) with the following format:
143 |
144 |
145 | v2 = 'Etsy API version 2 key goes here'
146 |
147 |
148 | Alternatively, you can specify a different key file when creating an API object.
149 |
150 |
151 | from etsy import Etsy
152 |
153 | api = Etsy(key_file='/usr/share/etsy/keys')
154 |
155 |
156 | (Implementation note: the keys file can be any valid python script that defines
157 | a module-level variable for the API version you are trying to use.)
158 |
159 | ## Tests
160 |
161 | This package comes with a reasonably complete unit test suite. In order to run
162 | the tests, use:
163 |
164 |
165 | $ python setup.py test
166 |
167 |
168 | Some tests (those that actually call the Etsy API) require your API key
169 | to be locally configured. See the Configuration section, above.
170 |
171 |
172 | ## Method Table Caching
173 |
174 | As mentioned above, this module is implemented by metaprogramming against the method table
175 | published by the Etsy API. In other words, API methods are not explicitly declared by the
176 | code in this module. Instead, the list of allowable methods is downloaded and
177 | the patched into the API objects at runtime.
178 |
179 | This has advantages and disadvantages. It allows the module to automatically
180 | receive new features, but on the other hand, this process is not as fast as
181 | explicitly declared methods.
182 |
183 | In order to speed things up, the method table json is cached locally by default.
184 | If a $HOME/etsy directory exists, the cache file is created there. Otherwise, it
185 | is placed in the machine's temp directory. By default, this cache lasts 24 hours.
186 |
187 | The cache file can be specified when creating an API object:
188 |
189 | ```python
190 | from etsy import Etsy
191 |
192 | api = Etsy(method_cache='myfile.json')
193 | ```
194 |
195 | Method table caching can also be disabled by passing None as the cache parameter:
196 |
197 | ```python
198 | from etsy import Etsy
199 |
200 | # do not cache methods
201 | api = Etsy(method_cache=None)
202 | ```
203 |
204 |
205 | ## Version History
206 |
207 | ### Version 0.7.0
208 | - Url parameters now sent in the url instead of the query string.
209 | - Url parameters now passed like every other parameter instead of optionally being able to be passed as positional arguments. Improves api consistency and cuts down on error cases.
210 | - Patched metadata for submitTracking during runtime to workaround inconsistency in etsy api metadata.
211 |
212 | ### Version 0.6.0
213 | - Added get_oauth_token_via_verifier to EtsyOAuthHelper. This allows users to obtain oauth credentials by manually passing the verification code
214 | rather than using a callback_url.
215 |
216 | ### Version 0.5.0
217 | - changed module name from etsy to etsy2 to match the package name on pypi (thanks to [James Tatum](https://github.com/jtatum)).
218 |
219 | ### Version 0.4.0
220 | - Added python 3 compatability
221 | - Removed EtsySandboxEnv because etsy doesn't seem to have a sandbox env anymore.
222 | - Fixed broken EtsyOauthClient because etsy now rejects calls including the api_key param when oauth is being used.
223 | - Replaced simplejson with builtin json, replaced python-oauth2 with requests-oauthlib (python-oauth2 only supports up to python 3.4).
224 | - Removed the oauth credential retrieval methods from EtsyOAuthClient to make client usage easier.
225 | - Created EtsyOAuthHelper to make retrieving the etsy oauth credentials easier.
226 | - Added helpers to make getting oauth credentials from etsy easier.
227 | - Added basic support for PUT and DELETE methods (which the etsy api didnt have when this was originally written)
228 |
229 | ### Version 0.3.1
230 | * Allowing Python Longs to be passed for parameters declared as "integers" by the API
231 | (thanks to [Marc Abramowitz](http://marc-abramowitz.com)).
232 |
233 |
234 | ### Version 0.3
235 | * Support for Etsy API v2 thanks to [Marc Abramowitz](http://marc-abramowitz.com).
236 | * Removed support for now-dead Etsy API v1.
237 |
238 |
239 | ### Version 0.2.1
240 | * Added a cache for the method table json.
241 | * Added a logging facility.
242 |
243 |
244 | ### Version 0.2 - 05-31-2010
245 | * Added local configuration (~/.etsy) to eliminate cutting & pasting of api keys.
246 | * Added client-side type checking for parameters.
247 | * Added support for positional arguments.
248 | * Added a test suite.
249 | * Began differentiation between API versions.
250 | * Added module to PyPI.
251 |
252 | ### Version 0.1 - 05-24-2010
253 | Initial release
254 |
--------------------------------------------------------------------------------
/etsy2/__init__.py:
--------------------------------------------------------------------------------
1 | from ._v2 import EtsyV2 as Etsy
2 | from .etsy_env import EtsyEnvProduction
3 |
4 |
5 | __version__ = '0.7.0'
6 | __author__ = 'Sean Scheetz'
7 | __copyright__ = 'Copyright 2010, Etsy Inc.'
8 | __license__ = 'GPL v3'
9 | __email__ = 'file_an_issue_on_github@gmail.com'
10 |
11 |
--------------------------------------------------------------------------------
/etsy2/_core.py:
--------------------------------------------------------------------------------
1 | from __future__ import with_statement
2 | import json
3 | from urllib.parse import urlencode, quote
4 | import os
5 | import re
6 | import tempfile
7 | import mimetypes
8 | import time
9 | import requests
10 |
11 |
12 | missing = object()
13 |
14 |
15 |
16 |
17 | class TypeChecker(object):
18 | def __init__(self):
19 | self.checkers = {
20 | 'int': self.check_int,
21 | 'float': self.check_float,
22 | 'string': self.check_string,
23 | 'boolean': self.check_boolean
24 | }
25 |
26 |
27 | def __call__(self, method, **kwargs):
28 | params = method['params']
29 | for k, v in kwargs.items():
30 | if k == 'includes': continue
31 |
32 | if k not in params:
33 | raise ValueError('Unexpected argument: %s=%s' % (k, v))
34 |
35 | t = params[k]
36 | checker = self.checkers.get(t, None) or self.compile(t)
37 | ok, converted = checker(v)
38 | if not ok:
39 | raise ValueError(
40 | "Bad value for parameter %s of type '%s' - %s" % (k, t, v))
41 | kwargs[k] = converted
42 |
43 |
44 | def compile(self, t):
45 | if t.startswith('enum'):
46 | f = self.compile_enum(t)
47 | else:
48 | # TODO write checkers for complex etsy types
49 | # https://www.etsy.com/developers/documentation/getting_started/api_basics#section_parameter_types
50 | f = self.always_ok
51 | self.checkers[t] = f
52 | return f
53 |
54 |
55 | def compile_enum(self, t):
56 | terms = [x.strip() for x in t[5:-1].split(',')]
57 | def check_enum(value):
58 | return (value in terms), value
59 | return check_enum
60 |
61 |
62 | def always_ok(self, value):
63 | return True, value
64 |
65 |
66 | def check_int(self, value):
67 | return isinstance(value, int), value
68 |
69 |
70 | def check_float(self, value):
71 | if isinstance(value, int):
72 | return True, value
73 | return isinstance(value, float), value
74 |
75 |
76 | def check_string(self, value):
77 | return isinstance(value, str), value
78 |
79 |
80 | def check_boolean(self, value):
81 | return isinstance(value, bool), value
82 |
83 |
84 | class APIMethod(object):
85 | def __init__(self, api, spec):
86 | """
87 | Parameters:
88 | api - API object that this method is associated with.
89 | spec - dict with the method specification; e.g.:
90 |
91 | {'name': 'createListing', 'uri': '/listings', 'visibility':
92 | 'private', 'http_method': 'POST', 'params': {'description':
93 | 'text', 'tags': 'array(string)', 'price': 'float', 'title':
94 | 'string', 'materials': 'array(string)', 'shipping_template_id':
95 | 'int', 'quantity': 'int', 'shop_section_id': 'int'}, 'defaults':
96 | {'materials': None, 'shop_section_id': None}, 'type': 'Listing',
97 | 'description': 'Creates a new Listing'}
98 | """
99 |
100 | self.api = api
101 | self.spec = spec
102 | self.type_checker = self.api.type_checker
103 | self.__doc__ = self.spec['description']
104 | self.compiled = False
105 |
106 | # HACK: etsy api metadata isn't correct for submitTracking.
107 | # We patch the correct data here.
108 | if self.spec['name'] == 'submitTracking':
109 | self.spec['params']['shop_id'] = 'shop_id_or_name'
110 | self.spec['params']['receipt_id'] = 'int'
111 |
112 |
113 | def __call__(self, **kwargs):
114 | if not self.compiled:
115 | self.compile()
116 | return self.invoke(**kwargs)
117 |
118 |
119 | def compile(self):
120 | uri = self.spec['uri']
121 | self.uri_params = [uri_param for uri_param in uri.split('/') if uri_param.startswith(':')]
122 | self.compiled = True
123 |
124 |
125 | def invoke(self, **kwargs):
126 | ps = {}
127 | for p in self.uri_params:
128 | # remove the starting ":" from the param
129 | kwarg_key = p[1:]
130 | if p[1:] not in kwargs:
131 | raise ValueError("Required argument '%s' not provided." % kwarg_key)
132 | # need to remove : from ps key as weel so it can be used in type_checker
133 | ps[kwarg_key] = kwargs[kwarg_key]
134 | del kwargs[kwarg_key]
135 |
136 | self.type_checker(self.spec, **ps)
137 | self.type_checker(self.spec, **kwargs)
138 |
139 | applied_url = self.spec['uri']
140 | for key, value in ps.items():
141 | applied_url = applied_url.replace(":" + key, quote(str(value)))
142 | return self.api._get(self.spec['http_method'], applied_url, **kwargs)
143 |
144 |
145 |
146 |
147 | class MethodTableCache(object):
148 | max_age = 60*60*24
149 |
150 | def __init__(self, api, method_cache):
151 | self.api = api
152 | self.filename = self.resolve_file(method_cache)
153 | self.used_cache = False
154 | self.wrote_cache = False
155 |
156 |
157 | def resolve_file(self, method_cache):
158 | if method_cache is missing:
159 | return self.default_file()
160 | return method_cache
161 |
162 |
163 | def etsy_home(self):
164 | return self.api.etsy_home()
165 |
166 |
167 | def default_file(self):
168 | etsy_home = self.etsy_home()
169 | d = etsy_home if os.path.isdir(etsy_home) else tempfile.gettempdir()
170 | return os.path.join(d, 'methods.%s.json' % self.api.api_version)
171 |
172 |
173 | def get(self):
174 | ms = self.get_cached()
175 | if not ms:
176 | ms = self.api.get_method_table()
177 | self.cache(ms)
178 | return ms
179 |
180 |
181 | def get_cached(self):
182 | if self.filename is None or not os.path.isfile(self.filename):
183 | self.api.log('Not using cached method table.')
184 | return None
185 | if time.time() - os.stat(self.filename).st_mtime > self.max_age:
186 | self.api.log('Method table too old.')
187 | return None
188 | with open(self.filename, 'r') as f:
189 | self.used_cache = True
190 | self.api.log('Reading method table cache: %s' % self.filename)
191 | return json.loads(f.read())
192 |
193 |
194 | def cache(self, methods):
195 | if self.filename is None:
196 | self.api.log('Method table caching disabled, not writing new cache.')
197 | return
198 | with open(self.filename, 'w') as f:
199 | json.dump(methods, f)
200 | self.wrote_cache = True
201 | self.api.log('Wrote method table cache: %s' % self.filename)
202 |
203 |
204 |
205 |
206 | class API(object):
207 | def __init__(self, api_key='', key_file=None, method_cache=missing,
208 | log=None):
209 | """
210 | Creates a new API instance. When called with no arguments,
211 | reads the appropriate API key from the default ($HOME/.etsy/keys)
212 | file.
213 |
214 | Parameters:
215 | api_key - An explicit API key to use.
216 | key_file - A file to read the API keys from.
217 | method_cache - A file to save the API method table in for
218 | 24 hours. This speeds up the creation of API
219 | objects.
220 | log - An callable that accepts a string parameter.
221 | Receives log messages. No logging is done if
222 | this is None.
223 |
224 | Only one of api_key and key_file may be passed.
225 |
226 | If method_cache is explicitly set to None, no method table
227 | caching is performed. If the parameter is not passed, a file in
228 | $HOME/.etsy is used if that directory exists. Otherwise, a
229 | temp file is used.
230 | """
231 | if not getattr(self, 'api_url', None):
232 | raise AssertionError('No api_url configured.')
233 |
234 | if self.api_url.endswith('/'):
235 | raise AssertionError('api_url should not end with a slash.')
236 |
237 | if not getattr(self, 'api_version', None):
238 | raise AssertionError('API object should define api_version')
239 |
240 | if api_key and key_file:
241 | raise AssertionError('Keys can be read from a file or passed, '
242 | 'but not both.')
243 |
244 | if api_key:
245 | self.api_key = api_key
246 | elif key_file:
247 | self.api_key = self._read_key(key_file)
248 |
249 | self.log = log or self._ignore
250 | if not callable(self.log):
251 | raise ValueError('log must be a callable.')
252 |
253 | self.type_checker = TypeChecker()
254 |
255 | self.decode = json.loads
256 |
257 | self.log('Creating %s Etsy API, base url=%s.' % (
258 | self.api_version, self.api_url))
259 | self._get_methods(method_cache)
260 |
261 |
262 |
263 | def _ignore(self, _):
264 | pass
265 |
266 |
267 | def _get_methods(self, method_cache):
268 | self.method_cache = MethodTableCache(self, method_cache)
269 | ms = self.method_cache.get()
270 | self._methods = dict([(m['name'], m) for m in ms])
271 |
272 | for method in ms:
273 | setattr(self, method['name'], APIMethod(self, method))
274 |
275 | # self.log('API._get_methods: self._methods = %r' % self._methods)
276 |
277 |
278 | def etsy_home(self):
279 | return os.path.expanduser('~/.etsy')
280 |
281 |
282 | def get_method_table(self):
283 | cache = self._get('GET', '/')
284 | return cache
285 |
286 |
287 | def _read_key(self, key_file):
288 | key_file = key_file or os.path.join(self.etsy_home(), 'keys')
289 | if not os.path.isfile(key_file):
290 | raise AssertionError(
291 | "The key file '%s' does not exist. Create a key file or "
292 | 'pass an API key explicitly.' % key_file)
293 |
294 | gs = {}
295 | with open(key_file) as kf:
296 | exec(kf.read(), gs)
297 | return gs[self.api_version]
298 |
299 |
300 | def _get_url(self, url, http_method, data):
301 | self.log("API._get_url: url = %r" % url)
302 | return requests.request(http_method, url, data=data)
303 |
304 | def _get(self, http_method, url, **kwargs):
305 | if hasattr(self, 'api_key'):
306 | kwargs.update(dict(api_key=self.api_key))
307 |
308 | data = None
309 | if http_method == 'GET' or http_method == 'DELETE':
310 | url = '%s%s' % (self.api_url, url)
311 | if kwargs:
312 | url += '?%s' % urlencode(kwargs)
313 | elif http_method == 'POST' or http_method == 'PUT':
314 | url = '%s%s' % (self.api_url, url)
315 |
316 | data = {}
317 | for name, value in kwargs.items():
318 | if hasattr(value, 'read'):
319 | file_mimetype = mimetypes.guess_type(value.name) or 'application/octet-stream'
320 | data[name] = (value.name, value.read(), file_mimetype)
321 | else:
322 | data[name] = (None, str(value))
323 |
324 | self.last_url = url
325 | response = self._get_url(url, http_method, data)
326 |
327 | self.log('API._get: http_method = %r, url = %r, data = %r' % (http_method, url, data))
328 |
329 | try:
330 | self.data = self.decode(response.text)
331 | except json.JSONDecodeError:
332 | raise ValueError('Could not decode response from Etsy as JSON: status_code: %r, text: %r, url %r' \
333 | % (response.status_code, response.text, response.url))
334 |
335 | self.count = self.data['count']
336 | return self.data['results']
337 |
--------------------------------------------------------------------------------
/etsy2/_v2.py:
--------------------------------------------------------------------------------
1 | import urllib
2 | from ._core import API, missing
3 | from .etsy_env import EtsyEnvProduction
4 |
5 | class EtsyV2(API):
6 | api_version = 'v2'
7 |
8 | def __init__(self, api_key='', key_file=None, method_cache=missing,
9 | etsy_env=EtsyEnvProduction(), log=None, etsy_oauth_client=None):
10 | self.api_url = etsy_env.api_url
11 | self.etsy_oauth_client = None
12 |
13 | if etsy_oauth_client:
14 | self.etsy_oauth_client = etsy_oauth_client
15 | # including api_key in requests when using oauth causes etsy to return 403 Forbidden
16 | api_key = None
17 | key_file = None
18 |
19 | super(EtsyV2, self).__init__(api_key, key_file, method_cache, log)
20 |
21 | def _get_url(self, url, http_method, body):
22 | if self.etsy_oauth_client is not None:
23 | return self.etsy_oauth_client.do_oauth_request(url, http_method, body)
24 | return API._get_url(self, url, http_method, body)
25 |
--------------------------------------------------------------------------------
/etsy2/etsy_env.py:
--------------------------------------------------------------------------------
1 | class EtsyEnvProduction(object):
2 | request_token_url = 'https://openapi.etsy.com/v2/oauth/request_token'
3 | access_token_url = 'https://openapi.etsy.com/v2/oauth/access_token'
4 | signin_url = 'https://www.etsy.com/oauth/signin'
5 | api_url = 'https://openapi.etsy.com/v2'
6 |
--------------------------------------------------------------------------------
/etsy2/oauth.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from urllib.parse import quote
3 | from requests_oauthlib import OAuth1Session
4 | from .etsy_env import EtsyEnvProduction
5 |
6 | # TODO add support for generating the oauth credentials - may want to inherit from OAuth1Session
7 | class EtsyOAuthClient():
8 | '''
9 | You must perform the oauth authentication on your own. This client
10 | expects valid oauth crendentials for the requesting user.
11 |
12 | client_key is keystring for the etsy app.
13 | client_secret is the shared secret for the etsy app.
14 | resource_owner_key is the oauth_token for the user whose data is being retrieved.
15 | resource_owner_secret is the oauth_token_secret for the user whose data is being retrieved.
16 | '''
17 | def __init__(self, client_key, client_secret, resource_owner_key, resource_owner_secret, logger=None):
18 | self.oauth1Session = OAuth1Session(client_key,
19 | client_secret=client_secret,
20 | resource_owner_key=resource_owner_key,
21 | resource_owner_secret=resource_owner_secret)
22 | self.logger = logger
23 |
24 | def do_oauth_request(self, url, http_method, data):
25 | # TODO data seems to work for PUT and POST /listing. See if data
26 | # can handle image/actual file data updates if so don't need to split path.
27 | if (http_method == "POST"):
28 | response = self.oauth1Session.request(http_method, url, files=data)
29 | else:
30 | response = self.oauth1Session.request(http_method, url, data=data)
31 |
32 | if self.logger:
33 | self.logger.debug('do_oauth_request: response = %r' % response)
34 |
35 | return response
36 |
37 | class EtsyOAuthHelper:
38 | '''
39 | Used to get the oauth token for the user you want to make requests with.
40 | TODO there might be a good way to make a class out of this, but because the
41 | methods are called in separate http requests the state would be lost in between
42 | calls.
43 | '''
44 | @staticmethod
45 | def get_request_url_and_token_secret(api_key, shared_secret, permission_scopes=[], callback_uri=None, etsy_env=EtsyEnvProduction()):
46 | '''
47 | This method implements the first step of the Oauth1.0 3-legged work flow.
48 |
49 | Returns the the login_url for the user you wish to authenticate with your app
50 | and the oauth_token_secret which must be passed to the get_oauth_token method
51 | (the next step in the oauth_workflow). You probably want to redirect the user
52 | to the login_url after this step.
53 |
54 | api_key is the keystring from etsy
55 | shared_secret is the shared secret from etsy
56 | permission_scopes is a list of strings. one string per requested permission scope. See link below.
57 | https://www.etsy.com/developers/documentation/getting_started/oauth#section_permission_scopes
58 | callback_uri is a path in your application where the user should be redirected after login
59 | etsy_env is always prod because there is only one etsy environment as of now
60 | '''
61 | oauth = OAuth1Session(api_key, client_secret=shared_secret, callback_uri=callback_uri)
62 |
63 | request_token_url = etsy_env.request_token_url
64 | if (permission_scopes):
65 | permissions = ' '.join(permission_scopes)
66 | request_token_url += '?scope=' + quote(permissions)
67 |
68 | request_token_response = oauth.fetch_request_token(request_token_url)
69 |
70 | login_url = request_token_response['login_url']
71 | temp_oauth_token_secret = request_token_response['oauth_token_secret']
72 |
73 | return (login_url, temp_oauth_token_secret)
74 |
75 | @staticmethod
76 | def get_oauth_token_via_auth_url(api_key, shared_secret, oauth_token_secret, auth_url, etsy_env=EtsyEnvProduction()):
77 | '''
78 | Retrieves the oauth_token and oauth_token_secret for the user. These are
79 | used along with the api_key, shared_secret, and oauth_token_secret (which
80 | is returned by get_request_url_and_token_secret) to create an EtsyOAuthClient.
81 |
82 | Differs from get_oauth_token_via_verifier as the auth_url containing the verifier and temp_oauth_token
83 | is passed in as a parameter. The auth_url is the url etsy redirected the user to after they allowed your app
84 | access to their data. It is the callback_url specified in get_request_url_and_token_secret with the query string
85 | etsy appended to it. This function will get the verifier and oauth_token from the auth_url query parameters.
86 |
87 | api_key is the keystring from etsy.
88 | shared_secret is the shared secret from etsy.
89 | oauth_token_secret is the token_secret returned from get_request_url_and_token_secret.
90 | auth_url is the url etsy redirected you to e.g. it is the callback url you specified
91 | in get_request_url_and_token_secret with the query string etsy appended to it.
92 | etsy_env is always prod because there is only one etsy environment as of now.
93 | '''
94 | oauth = OAuth1Session(api_key, shared_secret)
95 | oauth_response = oauth.parse_authorization_response(auth_url)
96 | oauth = OAuth1Session(api_key,
97 | client_secret=shared_secret,
98 | resource_owner_key=oauth_response['oauth_token'],
99 | resource_owner_secret=oauth_token_secret,
100 | verifier=oauth_response['oauth_verifier'])
101 |
102 | oauth_tokens = oauth.fetch_access_token(etsy_env.access_token_url)
103 | oauth_token = oauth_tokens['oauth_token']
104 | oauth_token_secret = oauth_tokens['oauth_token_secret']
105 |
106 | return (oauth_token, oauth_token_secret)
107 |
108 | @staticmethod
109 | def get_oauth_token_via_verifier(api_key, shared_secret, temp_oauth_token, temp_oauth_token_secret, verifier, etsy_env=EtsyEnvProduction()):
110 | '''
111 | Retrieves the oauth_token and oauth_token_secret for the user. These are
112 | used along with the api_key, shared_secret, and oauth_token_secret (which
113 | is returned by get_request_url_and_token_secret) to create an EtsyOAuthClient.
114 |
115 | Differs from get_oauth_token_via_auth_url as the temp_oauth_token and verifier are passed
116 | as params rather than retrieved from the auth_url. The temp_oauth_token is the oauth_token
117 | query string param from the login_url generated in get_request_url_and_token_secret.
118 |
119 | api_key is the keystring from etsy.
120 | shared_secret is the shared secret from etsy.
121 | temp_oauth_token is the oauth_token query string param from the login_url generated
122 | in get_request_url_and_token_secret.
123 | temp_oauth_token_secret is the token_secret returned from get_request_url_and_token_secret.
124 | verifier is the verification code on the page etsy redirects you to if no callback url
125 | was specified in get_request_url_and_token_secret.
126 | etsy_env is always prod because there is only one etsy environment as of now.
127 | '''
128 | oauth = OAuth1Session(api_key, shared_secret)
129 | oauth = OAuth1Session(api_key,
130 | client_secret=shared_secret,
131 | resource_owner_key=temp_oauth_token,
132 | resource_owner_secret=temp_oauth_token_secret,
133 | verifier=verifier)
134 |
135 | oauth_tokens = oauth.fetch_access_token(etsy_env.access_token_url)
136 | oauth_token = oauth_tokens['oauth_token']
137 | oauth_token_secret = oauth_tokens['oauth_token_secret']
138 |
139 | return (oauth_token, oauth_token_secret)
140 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | testpaths = test
3 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python
2 | import os
3 | from setuptools import setup
4 |
5 | this_dir = os.path.realpath(os.path.dirname(__file__))
6 | long_description = open(os.path.join(this_dir, 'README.md'), 'r').read()
7 |
8 | setup(
9 | name = 'etsy2',
10 | version = '0.7.0',
11 | author = 'Sean Scheetz',
12 | author_email = 'contact_through_github@gmail.com',
13 | description = 'Python access to the Etsy API',
14 | license = 'GPL v3',
15 | keywords = 'etsy api handmade',
16 | packages = ['etsy2'],
17 | long_description = long_description,
18 | long_description_content_type="text/markdown",
19 | test_suite = 'test',
20 | project_urls = {
21 | 'Source Code': 'https://github.com/sscheetz/etsy-python2'
22 | },
23 | install_requires=['requests_oauthlib'],
24 | )
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/test/test_core.py:
--------------------------------------------------------------------------------
1 | from urllib.parse import urlparse, parse_qs
2 | import os
3 | import tempfile
4 |
5 | from etsy2._core import API, MethodTableCache, missing
6 | from .util import Test
7 |
8 |
9 | class MockResponse():
10 | text = '{ "count": 2, "results": [1, 2] }'
11 |
12 |
13 | class MockAPI(API):
14 | api_url = 'http://host'
15 | api_version = 'v1'
16 |
17 |
18 | def etsy_home(self):
19 | return Test.scratch_dir
20 |
21 |
22 | def get_method_table(self, *args):
23 | return [{'name': 'testMethod',
24 | 'uri': '/test/:test_id',
25 | 'http_method': 'GET',
26 | 'params': {
27 | 'limit': 'int',
28 | 'test_id': 'user_id_or_name',
29 | 'offset': 'int',
30 | 'fizz': 'enum(foo, bar, baz)',
31 | 'buzz': 'float',
32 | 'blah': 'unknown type',
33 | 'kind': 'string',
34 | 'duh': 'boolean'
35 | },
36 | 'type': 'int',
37 | 'description': 'test method.'},
38 | {'name': 'method2',
39 | 'uri': '/blah',
40 | 'http_method': 'GET',
41 | 'params': {'foo': 'int'},
42 | 'type': 'int',
43 | 'description': 'so we can assert against 2 for test'}]
44 |
45 |
46 | def _get_url(self, url, http_method, data):
47 | return MockResponse()
48 |
49 |
50 |
51 | class MockLog(object):
52 | def __init__(self, test):
53 | self.lines = []
54 | self.test = test
55 |
56 | def __call__(self, msg):
57 | self.lines.append(msg)
58 |
59 |
60 | def assertLine(self, msg):
61 | failmsg = 'Could not find "%s" in the log. The log was:\n\n%s' % (
62 | msg, '\n'.join([' %s' % x for x in self.lines]))
63 | self.test.assertTrue(msg in self.lines, failmsg)
64 |
65 |
66 |
67 | class CoreTests(Test):
68 | def setUp(self):
69 | self.api = MockAPI('apikey', log=MockLog(self), method_cache=None)
70 |
71 |
72 | def last_query(self):
73 | qs = urlparse(self.api.last_url).query
74 | return parse_qs(qs)
75 |
76 |
77 | def test_method_created(self):
78 | self.assertTrue('testMethod' in dir(self.api))
79 |
80 |
81 | def test_url_params(self):
82 | self.api.testMethod(test_id='foo')
83 | self.assertEqual(self.api.last_url,
84 | 'http://host/test/foo?api_key=apikey')
85 |
86 |
87 | def test_count_saved(self):
88 | self.api.testMethod(test_id='foo')
89 | self.assertEqual(self.api.count, 2)
90 |
91 |
92 | def test_results_returned(self):
93 | x = self.api.testMethod(test_id='foo')
94 | self.assertEqual(x, [1,2])
95 |
96 |
97 | def test_query_params(self):
98 | self.api.testMethod(test_id='foo', limit=1)
99 | self.assertEqual(self.last_query(), {
100 | 'api_key': ['apikey'],
101 | 'limit': ['1'],
102 | })
103 |
104 |
105 | def test_docstring_set(self):
106 | self.assertEqual(self.api.testMethod.__doc__,
107 | 'test method.')
108 |
109 |
110 |
111 | def test_api_url_required(self):
112 | msg = self.assertRaises(AssertionError, API, '')
113 | self.assertEqual('No api_url configured.', msg)
114 |
115 |
116 | def test_api_url_cannot_end_with_slash(self):
117 | class Foo(API):
118 | api_url = 'http://host/'
119 |
120 | msg = self.assertRaises(AssertionError, Foo, '')
121 | self.assertEqual('api_url should not end with a slash.', msg)
122 |
123 |
124 | def test_api_should_define_version(self):
125 | class Foo(API):
126 | api_url = 'http://host'
127 |
128 | msg = self.assertRaises(AssertionError, Foo)
129 | self.assertEqual(msg, 'API object should define api_version')
130 |
131 |
132 | def test_key_file_does_not_exist(self):
133 | msg = self.assertRaises(AssertionError, MockAPI,
134 | key_file='this does not exist')
135 | self.assertTrue("'this does not exist' does not exist" in msg)
136 |
137 |
138 | def test_reading_api_key(self):
139 | with open('testkeys', 'w') as f:
140 | f.write("v1 = 'abcdef'")
141 | try:
142 | self.assertEqual(MockAPI(key_file='testkeys').api_key, 'abcdef')
143 | finally:
144 | os.unlink('testkeys')
145 |
146 |
147 | def test_unrecognized_kwarg(self):
148 | msg = self.assertRaises(ValueError, self.api.testMethod,
149 | test_id=1, not_an_arg=1)
150 | self.assertEqual(msg, 'Unexpected argument: not_an_arg=1')
151 |
152 |
153 | def test_unknown_parameter_type_is_passed(self):
154 | self.api.testMethod(test_id=1, blah=1)
155 | self.assertEqual(self.last_query()['blah'], ['1'])
156 |
157 |
158 | def test_missing_required_param(self):
159 | msg = self.assertRaises(ValueError, self.api.testMethod, limit=5.6)
160 | self.assertEqual(msg, "Required argument 'test_id' not provided.")
161 |
162 |
163 | def test_parameter_type_int(self):
164 | self.api.testMethod(test_id=1, limit=5)
165 | self.assertEqual(self.last_query()['limit'], ['5'])
166 |
167 |
168 | def test_parameter_type_boolean(self):
169 | self.api.testMethod(test_id=1, duh=True)
170 | self.assertEqual(self.last_query()['duh'], ['True'])
171 |
172 |
173 | def bad_value_msg(self, name, t, v):
174 | return "Bad value for parameter %s of type '%s' - %s" % (name, t, v)
175 |
176 | def test_invalid_parameter_type_int(self):
177 | msg = self.assertRaises(ValueError, self.api.testMethod,
178 | test_id=1, limit=5.6)
179 | self.assertEqual(msg, self.bad_value_msg('limit', 'int', 5.6))
180 |
181 |
182 | def test_parameter_type_float(self):
183 | self.api.testMethod(test_id=1, buzz=42.1)
184 | self.assertEqual(self.last_query()['buzz'], ['42.1'])
185 |
186 |
187 | def test_invalid_parameter_type_float(self):
188 | msg = self.assertRaises(ValueError, self.api.testMethod,
189 | test_id=1, buzz='x')
190 | self.assertEqual(msg, self.bad_value_msg('buzz', 'float', 'x'))
191 |
192 |
193 | def test_int_accepted_as_float(self):
194 | self.api.testMethod(test_id=1, buzz=3)
195 | self.assertEqual(self.last_query()['buzz'], ['3'])
196 |
197 |
198 | def test_parameter_type_enum(self):
199 | self.api.testMethod(test_id=1, fizz='bar')
200 | self.assertEqual(self.last_query()['fizz'], ['bar'])
201 |
202 |
203 | def test_invalid_parameter_type_enum(self):
204 | msg = self.assertRaises(ValueError, self.api.testMethod,
205 | test_id=1, fizz='goo')
206 | self.assertEqual(msg, self.bad_value_msg(
207 | 'fizz', 'enum(foo, bar, baz)', 'goo'))
208 |
209 |
210 | def test_parameter_type_string(self):
211 | self.api.testMethod(test_id=1, kind='blah')
212 | self.assertEqual(self.last_query()['kind'], ['blah'])
213 |
214 |
215 | def test_invalid_parameter_type_string(self):
216 | msg = self.assertRaises(ValueError, self.api.testMethod,
217 | test_id=1, kind=Test)
218 | self.assertEqual(msg, self.bad_value_msg('kind', 'string', Test))
219 |
220 |
221 | def test_api_key_and_key_file_both_passed(self):
222 | msg = self.assertRaises(AssertionError, MockAPI,
223 | api_key='x', key_file='y')
224 | self.assertEqual('Keys can be read from a file or passed, but not both.',
225 | msg)
226 |
227 |
228 | def test_logging_works(self):
229 | self.api.log('foo')
230 | self.api.log.assertLine('foo')
231 |
232 |
233 | def test_log_at_startup(self):
234 | self.api.log.assertLine('Creating v1 Etsy API, base url=http://host.')
235 |
236 |
237 |
238 |
239 |
240 |
241 | class MockAPI_NoMethods(MockAPI):
242 | def _get_methods(self, method_cache):
243 | pass
244 |
245 |
246 |
247 | class MethodTableCacheTests(Test):
248 |
249 |
250 | def cache(self, method_cache=missing):
251 | self.api = MockAPI_NoMethods('apikey')
252 | self._cache = MethodTableCache(self.api, method_cache)
253 | return self._cache
254 |
255 |
256 | def test_uses_etsy_home_if_exists(self):
257 | c = self.cache()
258 | self.assertEqual(os.path.dirname(c.filename), self.scratch_dir)
259 |
260 |
261 | def test_uses_temp_dir_if_no_etsy_home(self):
262 | self.delete_scratch()
263 | c = self.cache()
264 | self.assertEqual(os.path.dirname(c.filename), tempfile.gettempdir())
265 |
266 |
267 | def test_uses_provided_file(self):
268 | fn = os.path.join(self.scratch_dir, 'foo.json')
269 | self.assertEqual(self.cache(method_cache=fn).filename, fn)
270 |
271 |
272 | def test_multiple_versions(self):
273 | c = self.cache()
274 |
275 | class MockAPI2(MockAPI):
276 | api_version = 'v3'
277 |
278 | self.assertNotEqual(MockAPI2('key').method_cache.filename, c.filename)
279 |
280 |
281 | def get_uncached(self):
282 | c = self.cache()
283 | return c.get()
284 |
285 |
286 | def test_no_cache_file_returns_results(self):
287 | self.assertEqual(2, len(self.get_uncached()))
288 |
289 |
290 | def test_no_cache_file_writes_cache(self):
291 | self.get_uncached()
292 | self.assertTrue(self._cache.wrote_cache)
293 |
294 |
295 | def test_no_cache_file(self):
296 | self.get_uncached()
297 | self.assertFalse(self._cache.used_cache)
298 |
299 |
300 | def get_cached(self):
301 | c = self.cache()
302 | c.get()
303 | c = self.cache()
304 | return c.get()
305 |
306 |
307 | def test_caching(self):
308 | self.get_cached()
309 | self.assertTrue(self._cache.used_cache)
310 |
311 |
312 | def test_caching_returns_results(self):
313 | self.assertEqual(2, len(self.get_cached()))
314 |
315 |
316 | def test_caching_doesnt_overwrite_cache(self):
317 | self.get_cached()
318 | self.assertFalse(self._cache.wrote_cache)
319 |
320 |
321 | def make_old_cache(self):
322 | self.get_cached()
323 | fn = self._cache.filename
324 | s = os.stat(fn)
325 | os.utime(fn, (s.st_atime, s.st_mtime - 48*60*60))
326 |
327 |
328 | def test_expired(self):
329 | self.make_old_cache()
330 | c = self.cache()
331 | c.get()
332 | self.assertFalse(c.used_cache)
333 |
334 |
335 | def test_none_passed_does_not_cache(self):
336 | self.get_cached()
337 | c = self.cache(method_cache=None)
338 | c.get()
339 | self.assertFalse(c.used_cache)
340 |
341 |
342 | def log_tester(self, method_cache=missing):
343 | return MockAPI('key', method_cache=method_cache, log=MockLog(self))
344 |
345 |
346 | def test_logs_when_not_using_cache(self):
347 | api = self.log_tester(None)
348 | api.log.assertLine('Not using cached method table.')
349 |
350 |
351 | def test_logs_when_method_table_too_old(self):
352 | self.make_old_cache()
353 | self.log_tester().log.assertLine('Method table too old.')
354 |
355 |
356 | def test_logs_when_reading_cache(self):
357 | api = MockAPI('key')
358 | self.log_tester().log.assertLine('Reading method table cache: %s' %
359 | api.method_cache.filename)
360 |
361 |
362 | def test_logs_when_not_writing_new_cache(self):
363 | api = self.log_tester(None)
364 | api.log.assertLine(
365 | 'Method table caching disabled, not writing new cache.')
366 |
367 |
368 | def test_logs_when_writing_new_cache(self):
369 | t = self.log_tester()
370 | t.log.assertLine('Wrote method table cache: %s' %
371 | t.method_cache.filename)
372 |
373 |
--------------------------------------------------------------------------------
/test/util.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | import os
3 | import shutil
4 |
5 |
6 | this_dir = os.path.realpath(os.path.dirname(__file__))
7 |
8 |
9 | class Test(TestCase):
10 | scratch_dir = os.path.join(this_dir, 'scratch')
11 |
12 | def setUp(self):
13 | if not os.path.isdir(self.scratch_dir):
14 | os.mkdir(self.scratch_dir)
15 |
16 | def tearDown(self):
17 | self.delete_scratch()
18 |
19 | def delete_scratch(self):
20 | if os.path.isdir(self.scratch_dir):
21 | shutil.rmtree(self.scratch_dir)
22 |
23 | def assertRaises(self, cls, f, *args, **kwargs):
24 | try:
25 | f(*args, **kwargs)
26 | except cls as e:
27 | try:
28 | return e.strerror
29 | except:
30 | return e.args[0]
31 | else:
32 | name = cls.__name__ if hasattr(cls, '__name__') else str(cls)
33 | raise self.failureException("%s not raised" % name)
34 |
--------------------------------------------------------------------------------
/test_v2.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os, sys
4 | import oauth2 as oauth
5 | import webbrowser
6 | from etsy2 import Etsy, EtsyEnvProduction
7 | from .oauth import EtsyOAuthClient
8 |
9 | logging_enabled = True
10 | etsy_env = EtsyEnvProduction()
11 |
12 | def my_log(msg):
13 | if logging_enabled: print(msg)
14 |
15 | def write_config_file(oauth_token):
16 | os.umask(0o077)
17 | config_file = open('config.py', 'w')
18 |
19 | if config:
20 | config_file.write("oauth_consumer_key = %r\n" % config.oauth_consumer_key)
21 | config_file.write("oauth_consumer_secret = %r\n" % config.oauth_consumer_secret)
22 |
23 | if oauth_token:
24 | config_file.write("oauth_token_key = %r\n" % oauth_token.key)
25 | config_file.write("oauth_token_secret = %r\n" % oauth_token.secret)
26 |
27 | try:
28 | import config
29 | except ImportError:
30 | config = None
31 | write_config_file(oauth_token=None)
32 |
33 | if hasattr(config, 'oauth_consumer_key') and hasattr(config, 'oauth_consumer_secret'):
34 | oauth_client = EtsyOAuthClient(
35 | oauth_consumer_key=config.oauth_consumer_key,
36 | oauth_consumer_secret=config.oauth_consumer_secret,
37 | etsy_env=etsy_env)
38 | else:
39 | sys.stderr.write('ERROR: You must set oauth_consumer_key and oauth_consumer_secret in config.py\n')
40 | sys.exit(1)
41 |
42 | if hasattr(config, 'oauth_token_key') and hasattr(config, 'oauth_token_secret'):
43 | oauth_client.token = oauth.Token(
44 | key=config.oauth_token_key,
45 | secret=config.oauth_token_secret)
46 | else:
47 | webbrowser.open(oauth_client.get_signin_url())
48 | oauth_client.set_oauth_verifier(input('Enter OAuth verifier: '))
49 | write_config_file(oauth_client.token)
50 |
51 | etsy_api = Etsy(etsy_oauth_client=oauth_client, etsy_env=etsy_env, log=my_log)
52 |
53 | # print 'oauth access token: (key=%r; secret=%r)' % (oauth_client.token.key, oauth_client.token.secret)
54 |
55 | print('findAllShopListingsActive => %r' % etsy_api.findAllShopListingsActive(shop_id=config.user_id, sort_on='created', limit=1))
56 |
57 | # print('getListing => %r' % etsy_api.getListing(listing_id=63067548))
58 |
59 | print('findAllUserShippingTemplates => %r' % etsy_api.findAllUserShippingTemplates(user_id=config.user_id))
60 |
61 | # TODO write UPDATE/INSERT test that doesnt cost money
62 | # TODO write test that excerises boolean param types
63 |
64 | #def testCreateListing():
65 | # print("Creating listing...")
66 | #
67 | # result = etsy_api.createListing(
68 | # description=config.description,
69 | # title=config.title,
70 | # price=config.price,
71 | # tags=config.tags,
72 | # materials=config.materials,
73 | # shipping_template_id=config.shipping_template_id,
74 | # shop_section_id=config.shop_section_id,
75 | # quantity=config.quantity)
76 | #
77 | # listing_id = result[0]['listing_id']
78 | #
79 | # print("Created listing with listing id %d" % listing_id)
80 | #
81 | # result = etsy_api.uploadListingImage(listing_id=listing_id, image=config.image_file)
82 | #
83 | # print("Result of uploading image: %r" % result)
84 | #
85 | #testCreateListing()
86 |
87 |
--------------------------------------------------------------------------------