Merge lp:~ricardokirkner/click-toolbelt/split-store-api-namespace into lp:click-toolbelt

Proposed by Ricardo Kirkner
Status: Rejected
Rejected by: Ricardo Kirkner
Proposed branch: lp:~ricardokirkner/click-toolbelt/split-store-api-namespace
Merge into: lp:click-toolbelt
Diff against target: 3369 lines (+1546/-1521)
38 files modified
Makefile (+1/-1)
click_toolbelt/api/__init__.py (+0/-7)
click_toolbelt/api/_login.py (+0/-47)
click_toolbelt/api/_upload.py (+0/-264)
click_toolbelt/api/channels.py (+0/-23)
click_toolbelt/api/common.py (+0/-53)
click_toolbelt/api/info.py (+0/-19)
click_toolbelt/channels.py (+1/-1)
click_toolbelt/common.py (+3/-69)
click_toolbelt/info.py (+1/-1)
click_toolbelt/login.py (+1/-1)
click_toolbelt/tests/api/__init__.py (+0/-2)
click_toolbelt/tests/api/test_channels.py (+0/-145)
click_toolbelt/tests/api/test_common.py (+0/-143)
click_toolbelt/tests/api/test_info.py (+0/-48)
click_toolbelt/tests/api/test_login.py (+0/-118)
click_toolbelt/tests/api/test_upload.py (+0/-506)
click_toolbelt/tests/test_common.py (+2/-66)
click_toolbelt/tests/test_login.py (+1/-1)
click_toolbelt/tests/test_upload.py (+3/-3)
click_toolbelt/toolbelt.py (+1/-1)
click_toolbelt/upload.py (+1/-1)
setup.py (+1/-1)
storeapi/__init__.py (+7/-0)
storeapi/_login.py (+46/-0)
storeapi/_upload.py (+264/-0)
storeapi/channels.py (+23/-0)
storeapi/common.py (+117/-0)
storeapi/compat.py (+12/-0)
storeapi/constants.py (+10/-0)
storeapi/info.py (+19/-0)
storeapi/tests/__init__.py (+2/-0)
storeapi/tests/test_channels.py (+145/-0)
storeapi/tests/test_common.py (+203/-0)
storeapi/tests/test_info.py (+48/-0)
storeapi/tests/test_login.py (+120/-0)
storeapi/tests/test_upload.py (+505/-0)
tests.py (+9/-0)
To merge this branch: bzr merge lp:~ricardokirkner/click-toolbelt/split-store-api-namespace
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Disapprove
Fabián Ezequiel Gallina (community) Approve
Review via email: mp+281986@code.launchpad.net

Commit message

split store api into standalone namespace for easier vendoring

To post a comment you must log in.
Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote :

LGTM. Chat for reference:

2016-01-08 09:16 <pindonga> hi, need some advise whether to do a refactor or
                            not in click-toolbelt
2016-01-08 09:16 <pindonga> if anyone cares for a review
2016-01-08 09:17 <pindonga> https://code.launchpad.net/~ricardokirkner/click-toolbelt/split-store-api-namespace/+merge/281986
2016-01-08 09:17 <pindonga> basically... I started this work when it seemed
                            we couldn't snapcraft to use click-toolbelt as a
                            deb dependency
2016-01-08 09:18 <pindonga> the idea was to extract the shared bits into a
                            separate namespace so that the files could be
                            copied straight into snapcraft (for ease of
                            maintainability)
2016-01-08 09:18 <pindonga> now it looks like we might be able to package
                            click-toolbelt soon-ish
2016-01-08 09:18 <pindonga> so this refactor is not necessary anymore
2016-01-08 09:18 <pindonga> but maybe it's still worth doing
2016-01-08 09:23 <pedronis> pindonga: ultimately it still makes sense to
                            merge click-toolbelt into snapscraft (without
                            click bits), no?
2016-01-08 09:24 <pindonga> pedronis, in the long term, yes
2016-01-08 09:24 <pindonga> the q is how to best keep both alive until we
                            sunset click-toolbelt
2016-01-08 09:27 <pedronis> this looks right long term, even if you use
                            click-toolbelt as dep, later you remove
                            click-toolbet and put storeapi into snapcraft or
                            make a storeapi package instead if we have other
                            usages
2016-01-08 09:27 <pedronis> and code in snapcraft talks about things that
                            make sense to it
2016-01-08 09:27 [fgallina reads]
2016-01-08 09:31 <fgallina> pindonga: I think indeed this separation makes
                            sense, especially with latest comments by
                            pedronis.

review: Approve
Revision history for this message
Joe Talbott (joetalbott) wrote :

Could you use 'bzr mv' to get a less verbose patch?

Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

Superseded by https://code.launchpad.net/~ricardokirkner/click-toolbelt/split-store-api-namespace-take-2/+merge/282001 which used bzr mv instead of add+rm to reduce diff size and preserve history.

review: Disapprove

Unmerged revisions

50. By Ricardo Kirkner

split store api into standalone namespace for easier vendoring

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2014-02-24 12:43:21 +0000
3+++ Makefile 2016-01-08 11:50:10 +0000
4@@ -22,5 +22,5 @@
5 coverage:
6 @coverage erase
7 @coverage run --branch setup.py test
8- @coverage report --include='click_toolbelt/*' -m
9+ @coverage report --include='click_toolbelt/*,storeapi/*' -m
10
11
12=== removed directory 'click_toolbelt/api'
13=== removed file 'click_toolbelt/api/__init__.py'
14--- click_toolbelt/api/__init__.py 2015-12-21 18:41:57 +0000
15+++ click_toolbelt/api/__init__.py 1970-01-01 00:00:00 +0000
16@@ -1,7 +0,0 @@
17-# Copyright 2015 Canonical Ltd. This software is licensed under the
18-# GNU General Public License version 3 (see the file LICENSE).
19-
20-from .channels import get_channels, update_channels # noqa
21-from .info import get_info # noqa
22-from ._login import login # noqa
23-from ._upload import upload # noqa
24
25=== removed file 'click_toolbelt/api/_login.py'
26--- click_toolbelt/api/_login.py 2015-12-21 18:41:57 +0000
27+++ click_toolbelt/api/_login.py 1970-01-01 00:00:00 +0000
28@@ -1,47 +0,0 @@
29-# -*- coding: utf-8 -*-
30-# Copyright 2015 Canonical Ltd. This software is licensed under the
31-# GNU General Public License version 3 (see the file LICENSE).
32-from __future__ import absolute_import, unicode_literals
33-import os
34-
35-from ssoclient.v2 import (
36- ApiException,
37- UnexpectedApiError,
38- V2ApiClient,
39-)
40-
41-from click_toolbelt.constants import (
42- CLICK_TOOLBELT_PROJECT_NAME,
43- UBUNTU_SSO_API_ROOT_URL,
44-)
45-
46-
47-def login(email, password, otp=None, token_name=CLICK_TOOLBELT_PROJECT_NAME):
48- """Log in via the Ubuntu One SSO API.
49-
50- If successful, returns the oauth token data.
51- """
52- result = {
53- 'success': False,
54- 'body': None,
55- }
56-
57- api_endpoint = os.environ.get(
58- 'UBUNTU_SSO_API_ROOT_URL', UBUNTU_SSO_API_ROOT_URL)
59- client = V2ApiClient(endpoint=api_endpoint)
60- data = {
61- 'email': email,
62- 'password': password,
63- 'token_name': token_name,
64- }
65- if otp is not None:
66- data['otp'] = otp
67- try:
68- response = client.login(data=data)
69- result['body'] = response
70- result['success'] = True
71- except ApiException as err:
72- result['body'] = err.body
73- except UnexpectedApiError as err:
74- result['body'] = err.json_body
75- return result
76
77=== removed file 'click_toolbelt/api/_upload.py'
78--- click_toolbelt/api/_upload.py 2015-12-21 18:41:57 +0000
79+++ click_toolbelt/api/_upload.py 1970-01-01 00:00:00 +0000
80@@ -1,264 +0,0 @@
81-# Copyright 2015 Canonical Ltd. This software is licensed under the
82-# GNU General Public License version 3 (see the file LICENSE).
83-from __future__ import absolute_import, unicode_literals
84-import json
85-import logging
86-import os
87-import re
88-
89-from click_toolbelt.api.common import get_oauth_session
90-from click_toolbelt.common import (
91- is_scan_completed,
92- retry,
93-)
94-from click_toolbelt.compat import open, quote_plus, urljoin
95-from click_toolbelt.constants import (
96- CLICK_UPDOWN_UPLOAD_URL,
97- MYAPPS_API_ROOT_URL,
98- SCAN_STATUS_POLL_DELAY,
99- SCAN_STATUS_POLL_RETRIES,
100-)
101-
102-
103-logger = logging.getLogger(__name__)
104-
105-
106-def upload(binary_filename, metadata_filename='', metadata=None):
107- """Create a new upload based on a click/snap package."""
108-
109- # validate package filename
110- pattern = (r'(.*/)?(?P<name>[\w\-_\.]+)_'
111- '(?P<version>[\d\.]+)_(?P<arch>\w+)\.(click|snap)')
112- match = re.match(pattern, binary_filename)
113- if not match:
114- logger.info('Invalid package filename.')
115- return
116- name = match.groupdict()['name']
117-
118- logger.info('Uploading files...')
119- data = upload_files(binary_filename)
120- success = data.get('success', False)
121- errors = data.get('errors', [])
122- if not success:
123- logger.info('Upload failed:\n\n%s\n', '\n'.join(errors))
124- return False
125-
126- logger.info('Uploading new version...')
127- meta = read_metadata(metadata_filename)
128- meta.update(metadata or {})
129- result = upload_app(name, data, metadata=meta)
130- success = result.get('success', False)
131- errors = result.get('errors', [])
132- app_url = result.get('application_url', '')
133- revision = result.get('revision')
134-
135- if success:
136- logger.info('Application uploaded successfully.')
137- if revision:
138- logger.info('Uploaded as revision %s.', revision)
139- else:
140- logger.info('Upload did not complete.')
141-
142- if errors:
143- logger.info('Some errors were detected:\n\n%s\n\n',
144- '\n'.join(errors))
145-
146- if app_url:
147- logger.info('Please check out the application at: %s.\n',
148- app_url)
149-
150- return success
151-
152-
153-def upload_files(binary_filename):
154- """Upload a binary file to the Store.
155-
156- Submit a file to the click-updown service and return the
157- corresponding upload_id.
158- """
159- updown_url = os.environ.get('CLICK_UPDOWN_UPLOAD_URL',
160- CLICK_UPDOWN_UPLOAD_URL)
161- unscanned_upload_url = urljoin(updown_url, 'unscanned-upload/')
162- files = {'binary': open(binary_filename, 'rb')}
163-
164- result = {'success': False, 'errors': []}
165-
166- session = get_oauth_session()
167- if session is None:
168- result['errors'] = ['No valid credentials found.']
169- return result
170-
171- try:
172- response = session.post(
173- unscanned_upload_url,
174- files=files)
175- if response.ok:
176- response_data = response.json()
177- result.update({
178- 'success': response_data.get('successful', True),
179- 'upload_id': response_data['upload_id'],
180- 'binary_filesize': os.path.getsize(binary_filename),
181- 'source_uploaded': 'source' in files,
182- })
183- else:
184- logger.error(
185- 'There was an error uploading the package.\n'
186- 'Reason: %s\n'
187- 'Text: %s',
188- response.reason, response.text)
189- result['errors'] = [response.text]
190- except Exception as err:
191- logger.exception(
192- 'An unexpected error was found while uploading files.')
193- result['errors'] = [str(err)]
194- finally:
195- # make sure to close any open files used for request
196- for fd in files.values():
197- fd.close()
198-
199- return result
200-
201-
202-def read_metadata(metadata_filename):
203- """Return a dictionary of metadata as read from a json file."""
204- if metadata_filename:
205- with open(metadata_filename, 'r') as metadata_file:
206- # file is automatically closed by context manager
207- metadata = json.load(metadata_file)
208- else:
209- metadata = {}
210-
211- return metadata
212-
213-
214-def upload_app(name, upload_data, metadata=None):
215- """Request a new upload to be created for a given upload_id."""
216- upload_url = get_upload_url(name)
217-
218- result = {'success': False, 'errors': [],
219- 'application_url': '', 'revision': None}
220-
221- session = get_oauth_session()
222- if session is None:
223- result['errors'] = ['No valid credentials found.']
224- return result
225-
226- if metadata is None:
227- metadata = {}
228-
229- try:
230- data = get_post_data(upload_data, metadata=metadata)
231- files = get_post_files(metadata=metadata)
232-
233- response = session.post(upload_url, data=data, files=files)
234- if response.ok:
235- response_data = response.json()
236- status_url = response_data['status_url']
237- logger.info('Package submitted to %s', upload_url)
238- logger.info('Checking package status...')
239- completed, data = get_scan_data(session, status_url)
240- if completed:
241- logger.info('Package scan completed.')
242- message = data.get('message', '')
243- if not message:
244- result['success'] = True
245- result['revision'] = data.get('revision')
246- else:
247- result['errors'] = [message]
248- else:
249- result['errors'] = [
250- 'Package scan took too long.',
251- ]
252- status_web_url = response_data.get('web_status_url')
253- if status_web_url:
254- result['errors'].append(
255- 'Please check the status later at: %s.' % (
256- status_web_url),
257- )
258- result['application_url'] = data.get('application_url', '')
259- else:
260- logger.error(
261- 'There was an error uploading the application.\n'
262- 'Reason: %s\n'
263- 'Text: %s',
264- response.reason, response.text)
265- result['errors'] = [response.text]
266- except Exception as err:
267- logger.exception(
268- 'There was an error uploading the application.')
269- result['errors'] = [str(err)]
270- finally:
271- # make sure to close any open files used for request
272- for fname, fd in files:
273- fd.close()
274-
275- return result
276-
277-
278-def get_upload_url(name):
279- """Return the url of the uploaded package."""
280- myapps_api_url = os.environ.get('MYAPPS_API_ROOT_URL',
281- MYAPPS_API_ROOT_URL)
282- upload_url = urljoin(myapps_api_url, 'click-package-upload/')
283- upload_url += "%s/" % quote_plus(name)
284- return upload_url
285-
286-
287-def get_post_data(upload_data, metadata=None):
288- """Return the data to be posted in order to create the upload."""
289- data = {
290- 'updown_id': upload_data['upload_id'],
291- 'binary_filesize': upload_data['binary_filesize'],
292- 'source_uploaded': upload_data['source_uploaded'],
293- }
294- data.update({
295- key: value
296- for (key, value) in metadata.items()
297- if key not in (
298- # make sure not to override upload_id, binary_filesize and
299- # source_uploaded
300- 'upload_id', 'binary_filesize', 'source_uploaded',
301- # skip files as they will be added to the files argument
302- 'icon_256', 'icon', 'screenshots',
303- )
304- })
305- return data
306-
307-
308-def get_post_files(metadata=None):
309- """Return data about files to upload during the package upload request."""
310- files = []
311-
312- icon = metadata.get('icon', metadata.get('icon_256', ''))
313- if icon:
314- icon_file = open(icon, 'rb')
315- files.append(('icon_256', icon_file))
316-
317- screenshots = metadata.get('screenshots', [])
318- for screenshot in screenshots:
319- screenshot_file = open(screenshot, 'rb')
320- files.append(('screenshots', screenshot_file))
321-
322- return files
323-
324-
325-def get_scan_data(session, status_url):
326- """Return metadata about the state of the upload scan process."""
327- # initial retry after 5 seconds
328- # linear backoff after that
329- # abort after 5 retries
330- @retry(terminator=is_scan_completed,
331- retries=SCAN_STATUS_POLL_RETRIES,
332- delay=SCAN_STATUS_POLL_DELAY,
333- backoff=1, logger=logger)
334- def get_status():
335- return session.get(status_url)
336-
337- response, aborted = get_status()
338-
339- completed = False
340- data = {}
341- if not aborted:
342- completed = is_scan_completed(response)
343- data = response.json()
344- return completed, data
345
346=== removed file 'click_toolbelt/api/channels.py'
347--- click_toolbelt/api/channels.py 2015-12-09 19:10:19 +0000
348+++ click_toolbelt/api/channels.py 1970-01-01 00:00:00 +0000
349@@ -1,23 +0,0 @@
350-# -*- coding: utf-8 -*-
351-# Copyright 2015 Canonical Ltd. This software is licensed under the
352-# GNU General Public License version 3 (see the file LICENSE).
353-from __future__ import absolute_import, unicode_literals
354-
355-from click_toolbelt.api.common import myapps_api_call
356-
357-
358-def get_channels(session, package_name):
359- """Get current channels config for package through API."""
360- channels_endpoint = 'package-channels/%s/' % package_name
361- return myapps_api_call(channels_endpoint, session=session)
362-
363-
364-def update_channels(session, package_name, data):
365- """Update current channels config for package through API."""
366- channels_endpoint = 'package-channels/%s/' % package_name
367- result = myapps_api_call(channels_endpoint, method='POST',
368- data=data, session=session)
369- if result['success']:
370- result['errors'] = result['data']['errors']
371- result['data'] = result['data']['channels']
372- return result
373
374=== removed file 'click_toolbelt/api/common.py'
375--- click_toolbelt/api/common.py 2015-12-11 20:03:40 +0000
376+++ click_toolbelt/api/common.py 1970-01-01 00:00:00 +0000
377@@ -1,53 +0,0 @@
378-# Copyright 2015 Canonical Ltd. This software is licensed under the
379-# GNU General Public License version 3 (see the file LICENSE).
380-import json
381-import os
382-
383-import requests
384-from requests_oauthlib import OAuth1Session
385-
386-from click_toolbelt.compat import urljoin
387-from click_toolbelt.config import load_config
388-from click_toolbelt.constants import MYAPPS_API_ROOT_URL
389-
390-
391-def get_oauth_session():
392- """Return a client configured to allow oauth signed requests."""
393- config = load_config()
394- try:
395- session = OAuth1Session(
396- config['consumer_key'],
397- client_secret=config['consumer_secret'],
398- resource_owner_key=config['token_key'],
399- resource_owner_secret=config['token_secret'],
400- signature_method='PLAINTEXT',
401- )
402- except KeyError:
403- session = None
404- return session
405-
406-
407-def myapps_api_call(path, session=None, method='GET', data=None):
408- """Issue a request for a particular endpoint of the MyApps API."""
409- result = {'success': False, 'errors': [], 'data': None}
410- if session is not None:
411- client = session
412- else:
413- client = requests
414-
415- root_url = os.environ.get('MYAPPS_API_ROOT_URL', MYAPPS_API_ROOT_URL)
416- url = urljoin(root_url, path)
417- if method == 'GET':
418- response = client.get(url)
419- elif method == 'POST':
420- response = client.post(url, data=data and json.dumps(data) or None,
421- headers={'Content-Type': 'application/json'})
422- else:
423- raise ValueError('Method {} not supported'.format(method))
424-
425- if response.ok:
426- result['success'] = True
427- result['data'] = response.json()
428- else:
429- result['errors'] = [response.text]
430- return result
431
432=== removed file 'click_toolbelt/api/info.py'
433--- click_toolbelt/api/info.py 2015-12-09 19:10:19 +0000
434+++ click_toolbelt/api/info.py 1970-01-01 00:00:00 +0000
435@@ -1,19 +0,0 @@
436-# -*- coding: utf-8 -*-
437-# Copyright 2015 Canonical Ltd. This software is licensed under the
438-# GNU General Public License version 3 (see the file LICENSE).
439-from __future__ import absolute_import, unicode_literals
440-
441-from click_toolbelt.api.common import myapps_api_call
442-
443-
444-def get_info():
445- """Return information about the MyApps API.
446-
447- Returned data contains information about:
448- - version
449- - department
450- - license
451- - country
452- - channel
453- """
454- return myapps_api_call('')
455
456=== modified file 'click_toolbelt/channels.py'
457--- click_toolbelt/channels.py 2015-12-22 15:28:53 +0000
458+++ click_toolbelt/channels.py 2016-01-08 11:50:10 +0000
459@@ -4,11 +4,11 @@
460 import json
461 import logging
462
463-from click_toolbelt.api.channels import get_channels, update_channels
464 from click_toolbelt.common import (
465 Command,
466 CommandError,
467 )
468+from storeapi.channels import get_channels, update_channels
469
470
471 class Channels(Command):
472
473=== modified file 'click_toolbelt/common.py'
474--- click_toolbelt/common.py 2015-12-21 18:41:57 +0000
475+++ click_toolbelt/common.py 2016-01-08 11:50:10 +0000
476@@ -1,12 +1,11 @@
477 # Copyright 2015 Canonical Ltd. This software is licensed under the
478 # GNU General Public License version 3 (see the file LICENSE).
479 from __future__ import absolute_import, unicode_literals
480-import time
481-from functools import wraps
482
483 import cliff.command
484
485 from click_toolbelt.config import clear_config, load_config, save_config
486+from storeapi.common import get_oauth_session
487
488
489 class CommandError(Exception):
490@@ -29,73 +28,8 @@
491
492 def get_oauth_session(self):
493 """Return a client configured to allow oauth signed requests."""
494- # import here to avoid circular import
495- from click_toolbelt.api.common import get_oauth_session
496- return get_oauth_session()
497+ config = load_config()
498+ return get_oauth_session(config)
499
500 def take_action(self, parsed_args):
501 pass # pragma: no cover
502-
503-
504-def is_scan_completed(response):
505- """Return True if the response indicates the scan process completed."""
506- if response.ok:
507- return response.json().get('completed', False)
508- return False
509-
510-
511-def retry(terminator=None, retries=3, delay=3, backoff=2, logger=None):
512- """Decorate a function to automatically retry calling it on failure.
513-
514- Arguments:
515- - terminator: this should be a callable that returns a boolean;
516- it is used to determine if the function call was successful
517- and the retry loop should be stopped
518- - retries: an integer specifying the maximum number of retries
519- - delay: initial number of seconds to wait for the first retry
520- - backoff: exponential factor to use to adapt the delay between
521- subsequent retries
522- - logger: logging.Logger instance to use for logging
523-
524- The decorated function will return as soon as any of the following
525- conditions are met:
526-
527- 1. terminator evaluates function output as True
528- 2. there are no more retries left
529-
530- If the terminator callable is not provided, the function will be called
531- exactly once and will not be retried.
532-
533- """
534- def decorated(func):
535- if retries != int(retries) or retries < 0:
536- raise ValueError(
537- 'retries value must be a positive integer or zero')
538- if delay < 0:
539- raise ValueError('delay value must be positive')
540-
541- if backoff != int(backoff) or backoff < 1:
542- raise ValueError('backoff value must be a positive integer')
543-
544- @wraps(func)
545- def wrapped(*args, **kwargs):
546- retries_left, current_delay = retries, delay
547-
548- result = func(*args, **kwargs)
549- if terminator is not None:
550- while not terminator(result) and retries_left > 0:
551- msg = "... retrying in %d seconds" % current_delay
552- if logger:
553- logger.warning(msg)
554-
555- # sleep
556- time.sleep(current_delay)
557- retries_left -= 1
558- current_delay *= backoff
559-
560- # retry
561- result = func(*args, **kwargs)
562- return result, retries_left == 0
563-
564- return wrapped
565- return decorated
566
567=== modified file 'click_toolbelt/info.py'
568--- click_toolbelt/info.py 2015-12-09 12:59:24 +0000
569+++ click_toolbelt/info.py 2016-01-08 11:50:10 +0000
570@@ -6,8 +6,8 @@
571
572 from cliff.command import Command
573
574-from click_toolbelt.api.info import get_info
575 from click_toolbelt.common import CommandError
576+from storeapi.info import get_info
577
578
579 class Info(Command):
580
581=== modified file 'click_toolbelt/login.py'
582--- click_toolbelt/login.py 2015-12-21 18:41:57 +0000
583+++ click_toolbelt/login.py 2016-01-08 11:50:10 +0000
584@@ -4,7 +4,6 @@
585 from __future__ import absolute_import, unicode_literals
586 import logging
587
588-from click_toolbelt.api import login
589 from click_toolbelt.common import (
590 Command,
591 CommandError,
592@@ -12,6 +11,7 @@
593 from click_toolbelt.constants import (
594 CLICK_TOOLBELT_PROJECT_NAME,
595 )
596+from storeapi import login
597
598
599 class Login(Command):
600
601=== removed directory 'click_toolbelt/tests/api'
602=== removed file 'click_toolbelt/tests/api/__init__.py'
603--- click_toolbelt/tests/api/__init__.py 2015-12-09 14:06:23 +0000
604+++ click_toolbelt/tests/api/__init__.py 1970-01-01 00:00:00 +0000
605@@ -1,2 +0,0 @@
606-# Copyright 2015 Canonical Ltd. This software is licensed under the
607-# GNU General Public License version 3 (see the file LICENSE).
608
609=== removed file 'click_toolbelt/tests/api/test_channels.py'
610--- click_toolbelt/tests/api/test_channels.py 2015-12-21 16:05:53 +0000
611+++ click_toolbelt/tests/api/test_channels.py 1970-01-01 00:00:00 +0000
612@@ -1,145 +0,0 @@
613-# -*- coding: utf-8 -*-
614-# Copyright 2015 Canonical Ltd. This software is licensed under the
615-# GNU General Public License version 3 (see the file LICENSE).
616-from __future__ import absolute_import, unicode_literals
617-import json
618-
619-from mock import patch
620-
621-from click_toolbelt.api.channels import get_channels, update_channels
622-from click_toolbelt.tests.test_config import ConfigTestCase
623-
624-
625-class ChannelsAPITestCase(ConfigTestCase):
626-
627- def setUp(self):
628- super(ChannelsAPITestCase, self).setUp()
629-
630- # setup patches
631- oauth_session = 'click_toolbelt.api.common.get_oauth_session'
632- patcher = patch(oauth_session)
633- self.mock_get_oauth_session = patcher.start()
634- self.mock_session = self.mock_get_oauth_session.return_value
635- self.addCleanup(patcher.stop)
636-
637- self.mock_get = self.mock_session.get
638- self.mock_post = self.mock_session.post
639-
640- self.channels_data = [
641- {'channel': 'stable', 'current': {'revision': 2, 'version': '1'}},
642- {'channel': 'beta', 'current': {'revision': 4, 'version': '1.5'}},
643- {'channel': 'edge', 'current': None},
644- ]
645-
646- def set_channels_get_success_response(self):
647- mock_response = self.mock_get.return_value
648- mock_response.ok = True
649- mock_response.json.return_value = self.channels_data
650-
651- def set_channels_get_error_response(self, error_msg):
652- mock_response = self.mock_get.return_value
653- mock_response.ok = False
654- mock_response.text = error_msg
655-
656- def set_channels_post_success_response(self):
657- mock_response = self.mock_post.return_value
658- mock_response.ok = True
659- mock_response.json.return_value = {
660- 'success': True, 'errors': [], 'channels': self.channels_data
661- }
662-
663- def set_channels_post_failed_response(self, error_msg):
664- mock_response = self.mock_post.return_value
665- mock_response.ok = True
666- mock_response.json.return_value = {
667- 'success': True, 'errors': [error_msg],
668- 'channels': self.channels_data
669- }
670-
671- def set_channels_post_error_response(self, error_msg):
672- mock_response = self.mock_post.return_value
673- mock_response.ok = False
674- mock_response.text = error_msg
675-
676- def test_get_channels(self):
677- self.set_channels_get_success_response()
678-
679- data = get_channels(self.mock_session, 'package.name')
680-
681- expected = {
682- 'success': True,
683- 'errors': [],
684- 'data': self.channels_data,
685- }
686- self.assertEqual(data, expected)
687-
688- def test_get_channels_with_error_response(self):
689- error_msg = 'some error'
690- self.set_channels_get_error_response(error_msg)
691-
692- data = get_channels(self.mock_session, 'package.name')
693-
694- expected = {
695- 'success': False,
696- 'errors': [error_msg],
697- 'data': None,
698- }
699- self.assertEqual(data, expected)
700-
701- def test_get_channels_uses_environment_variables(self):
702- with patch('click_toolbelt.api.common.os.environ',
703- {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
704- get_channels(self.mock_session, 'package.name')
705- self.mock_get.assert_called_once_with(
706- 'http://example.com/package-channels/package.name/')
707-
708- def test_update_channels(self):
709- self.set_channels_post_success_response()
710-
711- data = update_channels(
712- self.mock_session, 'package.name', {'stable': 2})
713-
714- expected = {
715- 'success': True,
716- 'errors': [],
717- 'data': self.channels_data,
718- }
719- self.assertEqual(data, expected)
720-
721- def test_update_channels_with_error_response(self):
722- error_msg = 'some error'
723- self.set_channels_post_error_response(error_msg)
724-
725- data = update_channels(
726- self.mock_session, 'package.name', {'stable': 2})
727-
728- expected = {
729- 'success': False,
730- 'errors': [error_msg],
731- 'data': None,
732- }
733- self.assertEqual(data, expected)
734-
735- def test_update_channels_with_failed_response(self):
736- error_msg = 'some error'
737- self.set_channels_post_failed_response(error_msg)
738-
739- data = update_channels(
740- self.mock_session, 'package.name', {'stable': 2})
741-
742- expected = {
743- 'success': True,
744- 'errors': [error_msg],
745- 'data': self.channels_data,
746- }
747- self.assertEqual(data, expected)
748-
749- def test_update_channels_uses_environment_variables(self):
750- with patch('click_toolbelt.api.common.os.environ',
751- {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
752- update_channels(
753- self.mock_session, 'package.name', {'stable': 2})
754- self.mock_post.assert_called_once_with(
755- 'http://example.com/package-channels/package.name/',
756- data=json.dumps({'stable': 2}),
757- headers={'Content-Type': 'application/json'})
758
759=== removed file 'click_toolbelt/tests/api/test_common.py'
760--- click_toolbelt/tests/api/test_common.py 2015-12-11 20:03:40 +0000
761+++ click_toolbelt/tests/api/test_common.py 1970-01-01 00:00:00 +0000
762@@ -1,143 +0,0 @@
763-# Copyright 2015 Canonical Ltd. This software is licensed under the
764-# GNU General Public License version 3 (see the file LICENSE).
765-import json
766-from unittest import TestCase
767-
768-import responses
769-from mock import Mock, patch
770-from requests_oauthlib import OAuth1Session
771-
772-from click_toolbelt.api.common import get_oauth_session, myapps_api_call
773-
774-
775-class GetOAuthSessionTestCase(TestCase):
776-
777- def setUp(self):
778- super(GetOAuthSessionTestCase, self).setUp()
779- patcher = patch(
780- 'click_toolbelt.api.common.load_config')
781- self.mock_load_config = patcher.start()
782- self.addCleanup(patcher.stop)
783-
784- def test_get_oauth_session_when_no_config(self):
785- self.mock_load_config.return_value = {}
786- session = get_oauth_session()
787- self.assertIsNone(session)
788-
789- def test_get_oauth_session_when_partial_config(self):
790- self.mock_load_config.return_value = {
791- 'consumer_key': 'consumer-key',
792- 'consumer_secret': 'consumer-secret',
793- }
794- session = get_oauth_session()
795- self.assertIsNone(session)
796-
797- def test_get_oauth_session(self):
798- self.mock_load_config.return_value = {
799- 'consumer_key': 'consumer-key',
800- 'consumer_secret': 'consumer-secret',
801- 'token_key': 'token-key',
802- 'token_secret': 'token-secret',
803- }
804- session = get_oauth_session()
805- self.assertIsInstance(session, OAuth1Session)
806- self.assertEqual(session.auth.client.client_key, 'consumer-key')
807- self.assertEqual(session.auth.client.client_secret, 'consumer-secret')
808- self.assertEqual(session.auth.client.resource_owner_key, 'token-key')
809- self.assertEqual(session.auth.client.resource_owner_secret,
810- 'token-secret')
811-
812-
813-class ApiCallTestCase(TestCase):
814-
815- def setUp(self):
816- super(ApiCallTestCase, self).setUp()
817- p = patch('click_toolbelt.api.common.os')
818- mock_os = p.start()
819- self.addCleanup(p.stop)
820- mock_os.environ = {'MYAPPS_API_ROOT_URL': 'http://example.com'}
821-
822- @responses.activate
823- def test_get_success(self):
824- response_data = {'response': 'value'}
825- responses.add(responses.GET, 'http://example.com/path',
826- json=response_data)
827-
828- result = myapps_api_call('/path')
829- self.assertEqual(result, {
830- 'success': True,
831- 'data': response_data,
832- 'errors': [],
833- })
834-
835- @responses.activate
836- def test_get_error(self):
837- response_data = {'response': 'error'}
838- responses.add(responses.GET, 'http://example.com/path',
839- json=response_data, status=500)
840-
841- result = myapps_api_call('/path')
842- self.assertEqual(result, {
843- 'success': False,
844- 'data': None,
845- 'errors': [json.dumps(response_data)],
846- })
847-
848- @responses.activate
849- def test_post_success(self):
850- response_data = {'response': 'value'}
851- responses.add(responses.POST, 'http://example.com/path',
852- json=response_data)
853-
854- result = myapps_api_call('/path', method='POST')
855- self.assertEqual(result, {
856- 'success': True,
857- 'data': response_data,
858- 'errors': [],
859- })
860-
861- @responses.activate
862- def test_post_error(self):
863- response_data = {'response': 'value'}
864- responses.add(responses.POST, 'http://example.com/path',
865- json=response_data, status=500)
866-
867- result = myapps_api_call('/path', method='POST')
868- self.assertEqual(result, {
869- 'success': False,
870- 'data': None,
871- 'errors': [json.dumps(response_data)],
872- })
873-
874- def test_unsupported_method(self):
875- self.assertRaises(ValueError, myapps_api_call, '/path', method='FOO')
876-
877- def test_get_with_session(self):
878- session = Mock()
879- myapps_api_call('/path', session=session)
880- session.get.assert_called_once_with('http://example.com/path')
881-
882- def test_post_with_session(self):
883- session = Mock()
884- myapps_api_call('/path', method='POST', session=session)
885- session.post.assert_called_once_with(
886- 'http://example.com/path',
887- data=None, headers={'Content-Type': 'application/json'})
888-
889- @responses.activate
890- def test_post_with_data(self):
891- response_data = {'response': 'value'}
892- responses.add(responses.POST, 'http://example.com/path',
893- json=response_data)
894-
895- result = myapps_api_call('/path', method='POST', data={'request': 'value'})
896- self.assertEqual(result, {
897- 'success': True,
898- 'data': response_data,
899- 'errors': [],
900- })
901- self.assertEqual(len(responses.calls), 1)
902- self.assertEqual(responses.calls[0].request.headers['Content-Type'],
903- 'application/json')
904- self.assertEqual(responses.calls[0].request.body,
905- json.dumps({'request': 'value'}))
906
907=== removed file 'click_toolbelt/tests/api/test_info.py'
908--- click_toolbelt/tests/api/test_info.py 2015-12-09 19:10:19 +0000
909+++ click_toolbelt/tests/api/test_info.py 1970-01-01 00:00:00 +0000
910@@ -1,48 +0,0 @@
911-# -*- coding: utf-8 -*-
912-# Copyright 2015 Canonical Ltd. This software is licensed under the
913-# GNU General Public License version 3 (see the file LICENSE).
914-from __future__ import absolute_import, unicode_literals
915-from unittest import TestCase
916-
917-from mock import patch
918-
919-from click_toolbelt.api.info import get_info
920-
921-
922-class InfoAPITestCase(TestCase):
923-
924- def setUp(self):
925- super(InfoAPITestCase, self).setUp()
926-
927- patcher = patch('click_toolbelt.api.common.requests.get')
928- self.mock_get = patcher.start()
929- self.mock_response = self.mock_get.return_value
930- self.addCleanup(patcher.stop)
931-
932- def test_get_info(self):
933- expected = {
934- 'success': True,
935- 'errors': [],
936- 'data': {'version': 1},
937- }
938- self.mock_response.ok = True
939- self.mock_response.json.return_value = {'version': 1}
940- data = get_info()
941- self.assertEqual(data, expected)
942-
943- def test_get_info_with_error_response(self):
944- expected = {
945- 'success': False,
946- 'errors': ['some error'],
947- 'data': None,
948- }
949- self.mock_response.ok = False
950- self.mock_response.text = 'some error'
951- data = get_info()
952- self.assertEqual(data, expected)
953-
954- def test_get_info_uses_environment_variables(self):
955- with patch('click_toolbelt.api.common.os.environ',
956- {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
957- get_info()
958- self.mock_get.assert_called_once_with('http://example.com')
959
960=== removed file 'click_toolbelt/tests/api/test_login.py'
961--- click_toolbelt/tests/api/test_login.py 2015-12-21 18:41:57 +0000
962+++ click_toolbelt/tests/api/test_login.py 1970-01-01 00:00:00 +0000
963@@ -1,118 +0,0 @@
964-# -*- coding: utf-8 -*-
965-# Copyright 2015 Canonical Ltd. This software is licensed under the
966-# GNU General Public License version 3 (see the file LICENSE).
967-from __future__ import absolute_import, unicode_literals
968-import json
969-from unittest import TestCase
970-
971-from mock import patch
972-from requests import Response
973-
974-from click_toolbelt.api._login import login
975-from click_toolbelt.constants import (
976- CLICK_TOOLBELT_PROJECT_NAME,
977- UBUNTU_SSO_API_ROOT_URL,
978-)
979-
980-
981-class LoginAPITestCase(TestCase):
982-
983- def setUp(self):
984- super(LoginAPITestCase, self).setUp()
985- self.email = 'user@domain.com'
986- self.password = 'password'
987-
988- # setup patches
989- mock_environ = {
990- 'UBUNTU_SSO_API_ROOT_URL': UBUNTU_SSO_API_ROOT_URL,
991- }
992- patcher = patch('click_toolbelt.api._login.os.environ', mock_environ)
993- patcher.start()
994- self.addCleanup(patcher.stop)
995-
996- patcher = patch('ssoclient.v2.http.requests.Session.request')
997- self.mock_request = patcher.start()
998- self.addCleanup(patcher.stop)
999- self.token_data = {
1000- 'consumer_key': 'consumer-key',
1001- 'consumer_secret': 'consumer-secret',
1002- 'token_key': 'token-key',
1003- 'token_secret': 'token-secret',
1004- }
1005- response = self.make_response(status_code=201, reason='CREATED',
1006- data=self.token_data)
1007- self.mock_request.return_value = response
1008-
1009- def make_response(self, status_code=200, reason='OK', data=None):
1010- data = data or {}
1011- response = Response()
1012- response.status_code = status_code
1013- response.reason = reason
1014- response._content = json.dumps(data).encode('utf-8')
1015- return response
1016-
1017- def assert_login_request(self, otp=None,
1018- token_name=CLICK_TOOLBELT_PROJECT_NAME):
1019- data = {
1020- 'email': self.email,
1021- 'password': self.password,
1022- 'token_name': token_name
1023- }
1024- if otp is not None:
1025- data['otp'] = otp
1026- self.mock_request.assert_called_once_with(
1027- 'POST', UBUNTU_SSO_API_ROOT_URL + 'tokens/oauth',
1028- data=json.dumps(data),
1029- json=None, headers={'Content-Type': 'application/json'}
1030- )
1031-
1032- def test_login_successful(self):
1033- result = login(self.email, self.password)
1034- expected = {'success': True, 'body': self.token_data}
1035- self.assertEqual(result, expected)
1036-
1037- def test_default_token_name(self):
1038- result = login(self.email, self.password)
1039- expected = {'success': True, 'body': self.token_data}
1040- self.assertEqual(result, expected)
1041- self.assert_login_request()
1042-
1043- def test_custom_token_name(self):
1044- result = login(self.email, self.password, token_name='my-token')
1045- expected = {'success': True, 'body': self.token_data}
1046- self.assertEqual(result, expected)
1047- self.assert_login_request(token_name='my-token')
1048-
1049- def test_login_with_otp(self):
1050- result = login(self.email, self.password, otp='123456')
1051- expected = {'success': True, 'body': self.token_data}
1052- self.assertEqual(result, expected)
1053- self.assert_login_request(otp='123456')
1054-
1055- def test_login_unsuccessful_api_exception(self):
1056- error_data = {
1057- 'message': 'Error during login.',
1058- 'code': 'INVALID_CREDENTIALS',
1059- 'extra': {},
1060- }
1061- response = self.make_response(
1062- status_code=401, reason='UNAUTHORISED', data=error_data)
1063- self.mock_request.return_value = response
1064-
1065- result = login(self.email, self.password)
1066- expected = {'success': False, 'body': error_data}
1067- self.assertEqual(result, expected)
1068-
1069- def test_login_unsuccessful_unexpected_error(self):
1070- error_data = {
1071- 'message': 'Error during login.',
1072- 'code': 'UNEXPECTED_ERROR_CODE',
1073- 'extra': {},
1074- }
1075- response = self.make_response(
1076- status_code=401, reason='UNAUTHORISED', data=error_data)
1077- self.mock_request.return_value = response
1078-
1079- result = login(self.email, self.password)
1080- expected = {'success': False, 'body': error_data}
1081- self.assertEqual(result, expected)
1082
1083=== removed file 'click_toolbelt/tests/api/test_upload.py'
1084--- click_toolbelt/tests/api/test_upload.py 2015-12-21 18:41:57 +0000
1085+++ click_toolbelt/tests/api/test_upload.py 1970-01-01 00:00:00 +0000
1086@@ -1,506 +0,0 @@
1087-# Copyright 2015 Canonical Ltd. This software is licensed under the
1088-# GNU General Public License version 3 (see the file LICENSE).
1089-from __future__ import absolute_import, unicode_literals
1090-import json
1091-import os
1092-import tempfile
1093-
1094-from mock import ANY, patch
1095-from requests import Response
1096-
1097-from click_toolbelt.api._upload import (
1098- get_upload_url,
1099- upload_app,
1100- upload_files,
1101- upload,
1102-)
1103-from click_toolbelt.tests.test_config import (
1104- ConfigTestCase,
1105-)
1106-
1107-
1108-class UploadBaseTestCase(ConfigTestCase):
1109-
1110- def setUp(self):
1111- super(UploadBaseTestCase, self).setUp()
1112-
1113- # setup patches
1114- name = 'click_toolbelt.api._upload.get_oauth_session'
1115- patcher = patch(name)
1116- self.mock_get_oauth_session = patcher.start()
1117- self.addCleanup(patcher.stop)
1118-
1119- self.mock_get = self.mock_get_oauth_session.return_value.get
1120- self.mock_post = self.mock_get_oauth_session.return_value.post
1121-
1122-
1123-class UploadWithScanTestCase(UploadBaseTestCase):
1124-
1125- def setUp(self):
1126- super(UploadWithScanTestCase, self).setUp()
1127- self.suffix = '_0.1_all.click'
1128- self.binary_file = self.get_temporary_file(suffix=self.suffix)
1129-
1130- def test_default_metadata(self):
1131- mock_response = self.mock_post.return_value
1132- mock_response.ok = True
1133- mock_response.json.return_value = {
1134- 'successful': True,
1135- 'upload_id': 'some-valid-upload-id',
1136- }
1137-
1138- upload(self.binary_file.name)
1139-
1140- data = {
1141- 'updown_id': 'some-valid-upload-id',
1142- 'source_uploaded': False,
1143- 'binary_filesize': 0,
1144- }
1145- name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
1146- self.mock_post.assert_called_with(
1147- get_upload_url(name), data=data, files=[])
1148-
1149- def test_metadata_from_file(self):
1150- mock_response = self.mock_post.return_value
1151- mock_response.ok = True
1152- mock_response.json.return_value = {
1153- 'successful': True,
1154- 'upload_id': 'some-valid-upload-id',
1155- }
1156-
1157- with self.get_temporary_file() as metadata_file:
1158- data = json.dumps({'name': 'from_file'})
1159- metadata_file.write(data.encode('utf-8'))
1160- metadata_file.flush()
1161-
1162- upload(
1163- self.binary_file.name, metadata_filename=metadata_file.name)
1164-
1165- data = {
1166- 'updown_id': 'some-valid-upload-id',
1167- 'source_uploaded': False,
1168- 'binary_filesize': 0,
1169- 'name': 'from_file',
1170- }
1171- name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
1172- self.mock_post.assert_called_with(
1173- get_upload_url(name), data=data, files=[])
1174-
1175- def test_override_metadata(self):
1176- mock_response = self.mock_post.return_value
1177- mock_response.ok = True
1178- mock_response.json.return_value = {
1179- 'successful': True,
1180- 'upload_id': 'some-valid-upload-id',
1181- }
1182-
1183- upload(
1184- self.binary_file.name, metadata={'name': 'overridden'})
1185-
1186- data = {
1187- 'updown_id': 'some-valid-upload-id',
1188- 'source_uploaded': False,
1189- 'binary_filesize': 0,
1190- 'name': 'overridden',
1191- }
1192- name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
1193- self.mock_post.assert_called_with(
1194- get_upload_url(name), data=data, files=[])
1195-
1196-
1197-class UploadFilesTestCase(UploadBaseTestCase):
1198-
1199- def setUp(self):
1200- super(UploadFilesTestCase, self).setUp()
1201- self.binary_file = self.get_temporary_file(suffix='_0.1_all.click')
1202-
1203- def test_upload_files(self):
1204- mock_response = self.mock_post.return_value
1205- mock_response.ok = True
1206- mock_response.json.return_value = {
1207- 'successful': True,
1208- 'upload_id': 'some-valid-upload-id',
1209- }
1210-
1211- response = upload_files(self.binary_file.name)
1212- self.assertEqual(response, {
1213- 'success': True,
1214- 'errors': [],
1215- 'upload_id': 'some-valid-upload-id',
1216- 'binary_filesize': os.path.getsize(self.binary_file.name),
1217- 'source_uploaded': False,
1218- })
1219-
1220- def test_upload_files_uses_environment_variables(self):
1221- with patch.dict(os.environ,
1222- CLICK_UPDOWN_UPLOAD_URL='http://example.com'):
1223- upload_url = 'http://example.com/unscanned-upload/'
1224- upload_files(self.binary_file.name)
1225- self.mock_post.assert_called_once_with(
1226- upload_url, files={'binary': ANY})
1227-
1228- def test_upload_files_with_source_upload(self):
1229- mock_response = self.mock_post.return_value
1230- mock_response.ok = True
1231- mock_response.json.return_value = {
1232- 'successful': True,
1233- 'upload_id': 'some-valid-upload-id',
1234- }
1235-
1236- response = upload_files(self.binary_file.name)
1237- self.assertEqual(response, {
1238- 'success': True,
1239- 'errors': [],
1240- 'upload_id': 'some-valid-upload-id',
1241- 'binary_filesize': os.path.getsize(self.binary_file.name),
1242- 'source_uploaded': False,
1243- })
1244-
1245- def test_upload_files_with_invalid_oauth_session(self):
1246- self.mock_get_oauth_session.return_value = None
1247- response = upload_files(self.binary_file.name)
1248- self.assertEqual(response, {
1249- 'success': False,
1250- 'errors': ['No valid credentials found.'],
1251- })
1252- self.assertFalse(self.mock_post.called)
1253-
1254- def test_upload_files_error_response(self):
1255- mock_response = self.mock_post.return_value
1256- mock_response.ok = False
1257- mock_response.reason = '500 INTERNAL SERVER ERROR'
1258- mock_response.text = 'server failed'
1259-
1260- response = upload_files(self.binary_file.name)
1261- self.assertEqual(response, {
1262- 'success': False,
1263- 'errors': ['server failed'],
1264- })
1265-
1266- def test_upload_files_handle_malformed_response(self):
1267- mock_response = self.mock_post.return_value
1268- mock_response.json.return_value = {'successful': False}
1269-
1270- response = upload_files(self.binary_file.name)
1271- err = KeyError('upload_id')
1272- self.assertEqual(response, {
1273- 'success': False,
1274- 'errors': [str(err)],
1275- })
1276-
1277-
1278-class UploadAppTestCase(UploadBaseTestCase):
1279-
1280- def setUp(self):
1281- super(UploadAppTestCase, self).setUp()
1282- self.data = {
1283- 'upload_id': 'some-valid-upload-id',
1284- 'binary_filesize': 123456,
1285- 'source_uploaded': False,
1286- }
1287- self.package_name = 'namespace.binary'
1288-
1289- patcher = patch.multiple(
1290- 'click_toolbelt.api._upload',
1291- SCAN_STATUS_POLL_DELAY=0.0001)
1292- patcher.start()
1293- self.addCleanup(patcher.stop)
1294-
1295- def test_upload_app_with_invalid_oauth_session(self):
1296- self.mock_get_oauth_session.return_value = None
1297- response = upload_app(self.package_name, self.data)
1298- self.assertEqual(response, {
1299- 'success': False,
1300- 'errors': ['No valid credentials found.'],
1301- 'application_url': '',
1302- 'revision': None,
1303- })
1304-
1305- def test_upload_app_uses_environment_variables(self):
1306- with patch.dict(os.environ,
1307- MYAPPS_API_ROOT_URL='http://example.com'):
1308- upload_url = ("http://example.com/click-package-upload/%s/" %
1309- self.package_name)
1310- data = {
1311- 'updown_id': self.data['upload_id'],
1312- 'binary_filesize': self.data['binary_filesize'],
1313- 'source_uploaded': self.data['source_uploaded'],
1314- }
1315- upload_app(self.package_name, self.data)
1316- self.mock_post.assert_called_once_with(
1317- upload_url, data=data, files=[])
1318-
1319- def test_upload_app(self):
1320- mock_response = self.mock_post.return_value
1321- mock_response.ok = True
1322- mock_response.json.return_value = {
1323- 'success': True,
1324- 'status_url': 'http://example.com/status/'
1325- }
1326-
1327- mock_status_response = self.mock_get.return_value
1328- mock_status_response.ok = True
1329- mock_status_response.json.return_value = {
1330- 'completed': True,
1331- 'revision': 15,
1332- }
1333-
1334- response = upload_app(self.package_name, self.data)
1335- self.assertEqual(response, {
1336- 'success': True,
1337- 'errors': [],
1338- 'application_url': '',
1339- 'revision': 15,
1340- })
1341-
1342- def test_upload_app_error_response(self):
1343- mock_response = self.mock_post.return_value
1344- mock_response.ok = False
1345- mock_response.reason = '500 INTERNAL SERVER ERROR'
1346- mock_response.text = 'server failure'
1347-
1348- response = upload_app(self.package_name, self.data)
1349- self.assertEqual(response, {
1350- 'success': False,
1351- 'errors': ['server failure'],
1352- 'application_url': '',
1353- 'revision': None,
1354- })
1355-
1356- def test_upload_app_handle_malformed_response(self):
1357- mock_response = self.mock_post.return_value
1358- mock_response.ok = True
1359- mock_response.json.return_value = {}
1360-
1361- response = upload_app(self.package_name, self.data)
1362- err = KeyError('status_url')
1363- self.assertEqual(response, {
1364- 'success': False,
1365- 'errors': [str(err)],
1366- 'application_url': '',
1367- 'revision': None,
1368- })
1369-
1370- def test_upload_app_with_errors_during_scan(self):
1371- mock_response = self.mock_post.return_value
1372- mock_response.ok = True
1373- mock_response.json.return_value = {
1374- 'success': True,
1375- 'status_url': 'http://example.com/status/'
1376- }
1377-
1378- mock_status_response = self.mock_get.return_value
1379- mock_status_response.ok = True
1380- mock_status_response.json.return_value = {
1381- 'completed': True,
1382- 'message': 'some error',
1383- 'application_url': 'http://example.com/myapp',
1384- }
1385-
1386- response = upload_app(self.package_name, self.data)
1387- self.assertEqual(response, {
1388- 'success': False,
1389- 'errors': ['some error'],
1390- 'application_url': 'http://example.com/myapp',
1391- 'revision': None,
1392- })
1393-
1394- def test_upload_app_poll_status(self):
1395- mock_response = self.mock_post.return_value
1396- mock_response.ok = True
1397- mock_response.return_value = {
1398- 'success': True,
1399- 'status_url': 'http://example.com/status/'
1400- }
1401-
1402- response_not_completed = Response()
1403- response_not_completed.status_code = 200
1404- response_not_completed.encoding = 'utf-8'
1405- response_not_completed._content = json.dumps(
1406- {'completed': False, 'application_url': ''}).encode('utf-8')
1407- response_completed = Response()
1408- response_completed.status_code = 200
1409- response_completed.encoding = 'utf-8'
1410- response_completed._content = json.dumps(
1411- {'completed': True, 'revision': 14,
1412- 'application_url': 'http://example.org'}).encode('utf-8')
1413- self.mock_get.side_effect = [
1414- response_not_completed,
1415- response_not_completed,
1416- response_completed,
1417- ]
1418- response = upload_app(self.package_name, self.data)
1419- self.assertEqual(response, {
1420- 'success': True,
1421- 'errors': [],
1422- 'application_url': 'http://example.org',
1423- 'revision': 14,
1424- })
1425- self.assertEqual(self.mock_get.call_count, 3)
1426-
1427- def test_upload_app_ignore_non_ok_responses(self):
1428- mock_response = self.mock_post.return_value
1429- mock_response.ok = True
1430- mock_response.return_value = {
1431- 'success': True,
1432- 'status_url': 'http://example.com/status/',
1433- }
1434-
1435- ok_response = Response()
1436- ok_response.status_code = 200
1437- ok_response.encoding = 'utf-8'
1438- ok_response._content = json.dumps(
1439- {'completed': True, 'revision': 14}).encode('utf-8')
1440- nok_response = Response()
1441- nok_response.status_code = 503
1442-
1443- self.mock_get.side_effect = [nok_response, nok_response, ok_response]
1444- response = upload_app(self.package_name, self.data)
1445- self.assertEqual(response, {
1446- 'success': True,
1447- 'errors': [],
1448- 'application_url': '',
1449- 'revision': 14,
1450- })
1451- self.assertEqual(self.mock_get.call_count, 3)
1452-
1453- def test_upload_app_abort_polling(self):
1454- mock_response = self.mock_post.return_value
1455- mock_response.ok = True
1456- mock_response.json.return_value = {
1457- 'success': True,
1458- 'status_url': 'http://example.com/status/',
1459- 'web_status_url': 'http://example.com/status-web/',
1460- }
1461-
1462- mock_status_response = self.mock_get.return_value
1463- mock_status_response.ok = True
1464- mock_status_response.json.return_value = {
1465- 'completed': False
1466- }
1467- response = upload_app(self.package_name, self.data)
1468- self.assertEqual(response, {
1469- 'success': False,
1470- 'errors': [
1471- 'Package scan took too long.',
1472- 'Please check the status later at: '
1473- 'http://example.com/status-web/.',
1474- ],
1475- 'application_url': '',
1476- 'revision': None,
1477- })
1478-
1479- def test_upload_app_abort_polling_without_web_status_url(self):
1480- mock_response = self.mock_post.return_value
1481- mock_response.ok = True
1482- mock_response.json.return_value = {
1483- 'success': True,
1484- 'status_url': 'http://example.com/status/',
1485- }
1486-
1487- mock_status_response = self.mock_get.return_value
1488- mock_status_response.ok = True
1489- mock_status_response.json.return_value = {
1490- 'completed': False
1491- }
1492- response = upload_app(self.package_name, self.data)
1493- self.assertEqual(response, {
1494- 'success': False,
1495- 'errors': [
1496- 'Package scan took too long.',
1497- ],
1498- 'application_url': '',
1499- 'revision': None,
1500- })
1501-
1502- def test_upload_app_with_metadata(self):
1503- upload_app(self.package_name, self.data, metadata={
1504- 'changelog': 'some changes', 'tagline': 'a tagline'})
1505- self.mock_post.assert_called_once_with(
1506- ANY,
1507- data={
1508- 'updown_id': self.data['upload_id'],
1509- 'binary_filesize': self.data['binary_filesize'],
1510- 'source_uploaded': self.data['source_uploaded'],
1511- 'changelog': 'some changes',
1512- 'tagline': 'a tagline',
1513- },
1514- files=[],
1515- )
1516-
1517- def test_upload_app_ignore_special_attributes_in_metadata(self):
1518- upload_app(
1519- self.package_name,
1520- self.data, metadata={
1521- 'changelog': 'some changes',
1522- 'tagline': 'a tagline',
1523- 'upload_id': 'my-own-id',
1524- 'binary_filesize': 0,
1525- 'source_uploaded': False,
1526- })
1527- self.mock_post.assert_called_once_with(
1528- ANY,
1529- data={
1530- 'updown_id': self.data['upload_id'],
1531- 'binary_filesize': self.data['binary_filesize'],
1532- 'source_uploaded': self.data['source_uploaded'],
1533- 'changelog': 'some changes',
1534- 'tagline': 'a tagline',
1535- },
1536- files=[],
1537- )
1538-
1539- @patch('click_toolbelt.api._upload.open')
1540- def test_upload_app_with_icon(self, mock_open):
1541- with tempfile.NamedTemporaryFile() as icon:
1542- mock_open.return_value = icon
1543-
1544- upload_app(
1545- self.package_name, self.data,
1546- metadata={
1547- 'icon_256': icon.name,
1548- }
1549- )
1550- self.mock_post.assert_called_once_with(
1551- ANY,
1552- data={
1553- 'updown_id': self.data['upload_id'],
1554- 'binary_filesize': self.data['binary_filesize'],
1555- 'source_uploaded': self.data['source_uploaded'],
1556- },
1557- files=[
1558- ('icon_256', icon),
1559- ],
1560- )
1561-
1562- @patch('click_toolbelt.api._upload.open')
1563- def test_upload_app_with_screenshots(self, mock_open):
1564- screenshot1 = tempfile.NamedTemporaryFile()
1565- screenshot2 = tempfile.NamedTemporaryFile()
1566- mock_open.side_effect = [screenshot1, screenshot2]
1567-
1568- upload_app(
1569- self.package_name, self.data,
1570- metadata={
1571- 'screenshots': [screenshot1.name, screenshot2.name],
1572- }
1573- )
1574- self.mock_post.assert_called_once_with(
1575- ANY,
1576- data={
1577- 'updown_id': self.data['upload_id'],
1578- 'binary_filesize': self.data['binary_filesize'],
1579- 'source_uploaded': self.data['source_uploaded'],
1580- },
1581- files=[
1582- ('screenshots', screenshot1),
1583- ('screenshots', screenshot2),
1584- ],
1585- )
1586-
1587- def test_get_upload_url(self):
1588- with patch.dict(os.environ,
1589- MYAPPS_API_ROOT_URL='http://example.com'):
1590- upload_url = "http://example.com/click-package-upload/app.dev/"
1591- url = get_upload_url('app.dev')
1592- self.assertEqual(url, upload_url)
1593
1594=== modified file 'click_toolbelt/tests/test_common.py'
1595--- click_toolbelt/tests/test_common.py 2015-12-21 18:41:57 +0000
1596+++ click_toolbelt/tests/test_common.py 2016-01-08 11:50:10 +0000
1597@@ -1,13 +1,11 @@
1598 # Copyright 2015 Canonical Ltd. This software is licensed under the
1599 # GNU General Public License version 3 (see the file LICENSE).
1600 from __future__ import absolute_import, unicode_literals
1601-from unittest import TestCase
1602
1603-from mock import Mock, call, patch
1604+from mock import patch
1605
1606 from click_toolbelt.common import (
1607 Command,
1608- retry,
1609 )
1610 from click_toolbelt.tests.test_config import ConfigTestCase
1611
1612@@ -21,7 +19,7 @@
1613 args = None
1614 self.command = self.command_class(app, args)
1615
1616- patcher = patch('click_toolbelt.api.common.get_oauth_session')
1617+ patcher = patch('click_toolbelt.common.get_oauth_session')
1618 self.mock_get_oauth_session = patcher.start()
1619 self.addCleanup(patcher.stop)
1620
1621@@ -44,65 +42,3 @@
1622 def test_proxy_clear_config(self, mock_clear_config):
1623 self.command.clear_config()
1624 mock_clear_config.assert_called_once_with()
1625-
1626-
1627-class RetryDecoratorTestCase(TestCase):
1628-
1629- def target(self, *args, **kwargs):
1630- return dict(args=args, kwargs=kwargs)
1631-
1632- def test_retry(self):
1633- result, aborted = retry()(self.target)()
1634- self.assertEqual(result, dict(args=(), kwargs={}))
1635- self.assertEqual(aborted, False)
1636-
1637- @patch('click_toolbelt.common.time.sleep')
1638- def test_retry_small_backoff(self, mock_sleep):
1639- mock_terminator = Mock()
1640- mock_terminator.return_value = False
1641-
1642- delay = 0.001
1643- result, aborted = retry(mock_terminator, retries=2,
1644- delay=delay)(self.target)()
1645-
1646- self.assertEqual(result, dict(args=(), kwargs={}))
1647- self.assertEqual(aborted, True)
1648- self.assertEqual(mock_terminator.call_count, 3)
1649- self.assertEqual(mock_sleep.mock_calls, [
1650- call(delay),
1651- call(delay * 2),
1652- ])
1653-
1654- def test_retry_abort(self):
1655- mock_terminator = Mock()
1656- mock_terminator.return_value = False
1657- mock_logger = Mock()
1658-
1659- result, aborted = retry(mock_terminator, delay=0.001, backoff=1,
1660- logger=mock_logger)(self.target)()
1661-
1662- self.assertEqual(result, dict(args=(), kwargs={}))
1663- self.assertEqual(aborted, True)
1664- self.assertEqual(mock_terminator.call_count, 4)
1665- self.assertEqual(mock_logger.warning.call_count, 3)
1666-
1667- def test_retry_with_invalid_retries(self):
1668- for value in (0.1, -1):
1669- with self.assertRaises(ValueError) as ctx:
1670- retry(retries=value)(self.target)
1671- self.assertEqual(
1672- str(ctx.exception),
1673- 'retries value must be a positive integer or zero')
1674-
1675- def test_retry_with_negative_delay(self):
1676- with self.assertRaises(ValueError) as ctx:
1677- retry(delay=-1)(self.target)
1678- self.assertEqual(str(ctx.exception),
1679- 'delay value must be positive')
1680-
1681- def test_retry_with_invalid_backoff(self):
1682- for value in (-1, 0, 0.1):
1683- with self.assertRaises(ValueError) as ctx:
1684- retry(backoff=value)(self.target)
1685- self.assertEqual(str(ctx.exception),
1686- 'backoff value must be a positive integer')
1687
1688=== modified file 'click_toolbelt/tests/test_login.py'
1689--- click_toolbelt/tests/test_login.py 2015-12-22 15:28:53 +0000
1690+++ click_toolbelt/tests/test_login.py 2016-01-08 11:50:10 +0000
1691@@ -37,7 +37,7 @@
1692 mock_environ = {
1693 'UBUNTU_SSO_API_ROOT_URL': UBUNTU_SSO_API_ROOT_URL,
1694 }
1695- patcher = patch('click_toolbelt.api._login.os.environ', mock_environ)
1696+ patcher = patch('storeapi._login.os.environ', mock_environ)
1697 patcher.start()
1698 self.addCleanup(patcher.stop)
1699
1700
1701=== modified file 'click_toolbelt/tests/test_upload.py'
1702--- click_toolbelt/tests/test_upload.py 2015-12-21 18:41:57 +0000
1703+++ click_toolbelt/tests/test_upload.py 2016-01-08 11:50:10 +0000
1704@@ -25,13 +25,13 @@
1705 self.mock_get = self.mock_get_oauth_session.return_value.get
1706 self.mock_post = self.mock_get_oauth_session.return_value.post
1707
1708- p = patch('click_toolbelt.api._upload.logger')
1709+ p = patch('storeapi._upload.logger')
1710 self.mock_logger = p.start()
1711 self.addCleanup(p.stop)
1712- p = patch('click_toolbelt.api._upload.upload_files')
1713+ p = patch('storeapi._upload.upload_files')
1714 self.mock_upload_files = p.start()
1715 self.addCleanup(p.stop)
1716- p = patch('click_toolbelt.api._upload.upload_app')
1717+ p = patch('storeapi._upload.upload_app')
1718 self.mock_upload_app = p.start()
1719 self.addCleanup(p.stop)
1720
1721
1722=== modified file 'click_toolbelt/toolbelt.py'
1723--- click_toolbelt/toolbelt.py 2015-12-21 18:09:53 +0000
1724+++ click_toolbelt/toolbelt.py 2016-01-08 11:50:10 +0000
1725@@ -1,6 +1,6 @@
1726+#!/usr/bin/env python
1727 # Copyright 2013 Canonical Ltd. This software is licensed under the
1728 # GNU General Public License version 3 (see the file LICENSE).
1729-#!/usr/bin/env python
1730 from __future__ import absolute_import, unicode_literals
1731 import sys
1732
1733
1734=== modified file 'click_toolbelt/upload.py'
1735--- click_toolbelt/upload.py 2015-12-21 18:41:57 +0000
1736+++ click_toolbelt/upload.py 2016-01-08 11:50:10 +0000
1737@@ -3,11 +3,11 @@
1738 from __future__ import absolute_import, unicode_literals
1739 import logging
1740
1741-from click_toolbelt.api import upload
1742 from click_toolbelt.common import (
1743 Command,
1744 CommandError,
1745 )
1746+from storeapi import upload
1747
1748
1749 class Upload(Command):
1750
1751=== modified file 'setup.py'
1752--- setup.py 2015-12-09 19:32:37 +0000
1753+++ setup.py 2016-01-08 11:50:10 +0000
1754@@ -67,7 +67,7 @@
1755
1756 zip_safe=False,
1757
1758- test_suite='click_toolbelt.tests',
1759+ test_suite='tests',
1760 tests_require=[
1761 'mock',
1762 'responses',
1763
1764=== added directory 'storeapi'
1765=== added file 'storeapi/__init__.py'
1766--- storeapi/__init__.py 1970-01-01 00:00:00 +0000
1767+++ storeapi/__init__.py 2016-01-08 11:50:10 +0000
1768@@ -0,0 +1,7 @@
1769+# Copyright 2015 Canonical Ltd. This software is licensed under the
1770+# GNU General Public License version 3 (see the file LICENSE).
1771+
1772+from .channels import get_channels, update_channels # noqa
1773+from .info import get_info # noqa
1774+from ._login import login # noqa
1775+from ._upload import upload # noqa
1776
1777=== added file 'storeapi/_login.py'
1778--- storeapi/_login.py 1970-01-01 00:00:00 +0000
1779+++ storeapi/_login.py 2016-01-08 11:50:10 +0000
1780@@ -0,0 +1,46 @@
1781+# -*- coding: utf-8 -*-
1782+# Copyright 2015 Canonical Ltd. This software is licensed under the
1783+# GNU General Public License version 3 (see the file LICENSE).
1784+from __future__ import absolute_import, unicode_literals
1785+import os
1786+
1787+from ssoclient.v2 import (
1788+ ApiException,
1789+ UnexpectedApiError,
1790+ V2ApiClient,
1791+)
1792+
1793+from storeapi.constants import (
1794+ UBUNTU_SSO_API_ROOT_URL,
1795+)
1796+
1797+
1798+def login(email, password, token_name, otp=None):
1799+ """Log in via the Ubuntu One SSO API.
1800+
1801+ If successful, returns the oauth token data.
1802+ """
1803+ result = {
1804+ 'success': False,
1805+ 'body': None,
1806+ }
1807+
1808+ api_endpoint = os.environ.get(
1809+ 'UBUNTU_SSO_API_ROOT_URL', UBUNTU_SSO_API_ROOT_URL)
1810+ client = V2ApiClient(endpoint=api_endpoint)
1811+ data = {
1812+ 'email': email,
1813+ 'password': password,
1814+ 'token_name': token_name,
1815+ }
1816+ if otp is not None:
1817+ data['otp'] = otp
1818+ try:
1819+ response = client.login(data=data)
1820+ result['body'] = response
1821+ result['success'] = True
1822+ except ApiException as err:
1823+ result['body'] = err.body
1824+ except UnexpectedApiError as err:
1825+ result['body'] = err.json_body
1826+ return result
1827
1828=== added file 'storeapi/_upload.py'
1829--- storeapi/_upload.py 1970-01-01 00:00:00 +0000
1830+++ storeapi/_upload.py 2016-01-08 11:50:10 +0000
1831@@ -0,0 +1,264 @@
1832+# Copyright 2015 Canonical Ltd. This software is licensed under the
1833+# GNU General Public License version 3 (see the file LICENSE).
1834+from __future__ import absolute_import, unicode_literals
1835+import json
1836+import logging
1837+import os
1838+import re
1839+
1840+from storeapi.common import (
1841+ get_oauth_session,
1842+ is_scan_completed,
1843+ retry,
1844+)
1845+from storeapi.compat import open, quote_plus, urljoin
1846+from storeapi.constants import (
1847+ CLICK_UPDOWN_UPLOAD_URL,
1848+ MYAPPS_API_ROOT_URL,
1849+ SCAN_STATUS_POLL_DELAY,
1850+ SCAN_STATUS_POLL_RETRIES,
1851+)
1852+
1853+
1854+logger = logging.getLogger(__name__)
1855+
1856+
1857+def upload(binary_filename, metadata_filename='', metadata=None):
1858+ """Create a new upload based on a click/snap package."""
1859+
1860+ # validate package filename
1861+ pattern = (r'(.*/)?(?P<name>[\w\-_\.]+)_'
1862+ '(?P<version>[\d\.]+)_(?P<arch>\w+)\.(click|snap)')
1863+ match = re.match(pattern, binary_filename)
1864+ if not match:
1865+ logger.info('Invalid package filename.')
1866+ return
1867+ name = match.groupdict()['name']
1868+
1869+ logger.info('Uploading files...')
1870+ data = upload_files(binary_filename)
1871+ success = data.get('success', False)
1872+ errors = data.get('errors', [])
1873+ if not success:
1874+ logger.info('Upload failed:\n\n%s\n', '\n'.join(errors))
1875+ return False
1876+
1877+ logger.info('Uploading new version...')
1878+ meta = read_metadata(metadata_filename)
1879+ meta.update(metadata or {})
1880+ result = upload_app(name, data, metadata=meta)
1881+ success = result.get('success', False)
1882+ errors = result.get('errors', [])
1883+ app_url = result.get('application_url', '')
1884+ revision = result.get('revision')
1885+
1886+ if success:
1887+ logger.info('Application uploaded successfully.')
1888+ if revision:
1889+ logger.info('Uploaded as revision %s.', revision)
1890+ else:
1891+ logger.info('Upload did not complete.')
1892+
1893+ if errors:
1894+ logger.info('Some errors were detected:\n\n%s\n\n',
1895+ '\n'.join(errors))
1896+
1897+ if app_url:
1898+ logger.info('Please check out the application at: %s.\n',
1899+ app_url)
1900+
1901+ return success
1902+
1903+
1904+def upload_files(binary_filename):
1905+ """Upload a binary file to the Store.
1906+
1907+ Submit a file to the click-updown service and return the
1908+ corresponding upload_id.
1909+ """
1910+ updown_url = os.environ.get('CLICK_UPDOWN_UPLOAD_URL',
1911+ CLICK_UPDOWN_UPLOAD_URL)
1912+ unscanned_upload_url = urljoin(updown_url, 'unscanned-upload/')
1913+ files = {'binary': open(binary_filename, 'rb')}
1914+
1915+ result = {'success': False, 'errors': []}
1916+
1917+ session = get_oauth_session()
1918+ if session is None:
1919+ result['errors'] = ['No valid credentials found.']
1920+ return result
1921+
1922+ try:
1923+ response = session.post(
1924+ unscanned_upload_url,
1925+ files=files)
1926+ if response.ok:
1927+ response_data = response.json()
1928+ result.update({
1929+ 'success': response_data.get('successful', True),
1930+ 'upload_id': response_data['upload_id'],
1931+ 'binary_filesize': os.path.getsize(binary_filename),
1932+ 'source_uploaded': 'source' in files,
1933+ })
1934+ else:
1935+ logger.error(
1936+ 'There was an error uploading the package.\n'
1937+ 'Reason: %s\n'
1938+ 'Text: %s',
1939+ response.reason, response.text)
1940+ result['errors'] = [response.text]
1941+ except Exception as err:
1942+ logger.exception(
1943+ 'An unexpected error was found while uploading files.')
1944+ result['errors'] = [str(err)]
1945+ finally:
1946+ # make sure to close any open files used for request
1947+ for fd in files.values():
1948+ fd.close()
1949+
1950+ return result
1951+
1952+
1953+def read_metadata(metadata_filename):
1954+ """Return a dictionary of metadata as read from a json file."""
1955+ if metadata_filename:
1956+ with open(metadata_filename, 'r') as metadata_file:
1957+ # file is automatically closed by context manager
1958+ metadata = json.load(metadata_file)
1959+ else:
1960+ metadata = {}
1961+
1962+ return metadata
1963+
1964+
1965+def upload_app(name, upload_data, metadata=None):
1966+ """Request a new upload to be created for a given upload_id."""
1967+ upload_url = get_upload_url(name)
1968+
1969+ result = {'success': False, 'errors': [],
1970+ 'application_url': '', 'revision': None}
1971+
1972+ session = get_oauth_session()
1973+ if session is None:
1974+ result['errors'] = ['No valid credentials found.']
1975+ return result
1976+
1977+ if metadata is None:
1978+ metadata = {}
1979+
1980+ try:
1981+ data = get_post_data(upload_data, metadata=metadata)
1982+ files = get_post_files(metadata=metadata)
1983+
1984+ response = session.post(upload_url, data=data, files=files)
1985+ if response.ok:
1986+ response_data = response.json()
1987+ status_url = response_data['status_url']
1988+ logger.info('Package submitted to %s', upload_url)
1989+ logger.info('Checking package status...')
1990+ completed, data = get_scan_data(session, status_url)
1991+ if completed:
1992+ logger.info('Package scan completed.')
1993+ message = data.get('message', '')
1994+ if not message:
1995+ result['success'] = True
1996+ result['revision'] = data.get('revision')
1997+ else:
1998+ result['errors'] = [message]
1999+ else:
2000+ result['errors'] = [
2001+ 'Package scan took too long.',
2002+ ]
2003+ status_web_url = response_data.get('web_status_url')
2004+ if status_web_url:
2005+ result['errors'].append(
2006+ 'Please check the status later at: %s.' % (
2007+ status_web_url),
2008+ )
2009+ result['application_url'] = data.get('application_url', '')
2010+ else:
2011+ logger.error(
2012+ 'There was an error uploading the application.\n'
2013+ 'Reason: %s\n'
2014+ 'Text: %s',
2015+ response.reason, response.text)
2016+ result['errors'] = [response.text]
2017+ except Exception as err:
2018+ logger.exception(
2019+ 'There was an error uploading the application.')
2020+ result['errors'] = [str(err)]
2021+ finally:
2022+ # make sure to close any open files used for request
2023+ for fname, fd in files:
2024+ fd.close()
2025+
2026+ return result
2027+
2028+
2029+def get_upload_url(name):
2030+ """Return the url of the uploaded package."""
2031+ myapps_api_url = os.environ.get('MYAPPS_API_ROOT_URL',
2032+ MYAPPS_API_ROOT_URL)
2033+ upload_url = urljoin(myapps_api_url, 'click-package-upload/')
2034+ upload_url += "%s/" % quote_plus(name)
2035+ return upload_url
2036+
2037+
2038+def get_post_data(upload_data, metadata=None):
2039+ """Return the data to be posted in order to create the upload."""
2040+ data = {
2041+ 'updown_id': upload_data['upload_id'],
2042+ 'binary_filesize': upload_data['binary_filesize'],
2043+ 'source_uploaded': upload_data['source_uploaded'],
2044+ }
2045+ data.update({
2046+ key: value
2047+ for (key, value) in metadata.items()
2048+ if key not in (
2049+ # make sure not to override upload_id, binary_filesize and
2050+ # source_uploaded
2051+ 'upload_id', 'binary_filesize', 'source_uploaded',
2052+ # skip files as they will be added to the files argument
2053+ 'icon_256', 'icon', 'screenshots',
2054+ )
2055+ })
2056+ return data
2057+
2058+
2059+def get_post_files(metadata=None):
2060+ """Return data about files to upload during the package upload request."""
2061+ files = []
2062+
2063+ icon = metadata.get('icon', metadata.get('icon_256', ''))
2064+ if icon:
2065+ icon_file = open(icon, 'rb')
2066+ files.append(('icon_256', icon_file))
2067+
2068+ screenshots = metadata.get('screenshots', [])
2069+ for screenshot in screenshots:
2070+ screenshot_file = open(screenshot, 'rb')
2071+ files.append(('screenshots', screenshot_file))
2072+
2073+ return files
2074+
2075+
2076+def get_scan_data(session, status_url):
2077+ """Return metadata about the state of the upload scan process."""
2078+ # initial retry after 5 seconds
2079+ # linear backoff after that
2080+ # abort after 5 retries
2081+ @retry(terminator=is_scan_completed,
2082+ retries=SCAN_STATUS_POLL_RETRIES,
2083+ delay=SCAN_STATUS_POLL_DELAY,
2084+ backoff=1, logger=logger)
2085+ def get_status():
2086+ return session.get(status_url)
2087+
2088+ response, aborted = get_status()
2089+
2090+ completed = False
2091+ data = {}
2092+ if not aborted:
2093+ completed = is_scan_completed(response)
2094+ data = response.json()
2095+ return completed, data
2096
2097=== added file 'storeapi/channels.py'
2098--- storeapi/channels.py 1970-01-01 00:00:00 +0000
2099+++ storeapi/channels.py 2016-01-08 11:50:10 +0000
2100@@ -0,0 +1,23 @@
2101+# -*- coding: utf-8 -*-
2102+# Copyright 2015 Canonical Ltd. This software is licensed under the
2103+# GNU General Public License version 3 (see the file LICENSE).
2104+from __future__ import absolute_import, unicode_literals
2105+
2106+from storeapi.common import myapps_api_call
2107+
2108+
2109+def get_channels(session, package_name):
2110+ """Get current channels config for package through API."""
2111+ channels_endpoint = 'package-channels/%s/' % package_name
2112+ return myapps_api_call(channels_endpoint, session=session)
2113+
2114+
2115+def update_channels(session, package_name, data):
2116+ """Update current channels config for package through API."""
2117+ channels_endpoint = 'package-channels/%s/' % package_name
2118+ result = myapps_api_call(channels_endpoint, method='POST',
2119+ data=data, session=session)
2120+ if result['success']:
2121+ result['errors'] = result['data']['errors']
2122+ result['data'] = result['data']['channels']
2123+ return result
2124
2125=== added file 'storeapi/common.py'
2126--- storeapi/common.py 1970-01-01 00:00:00 +0000
2127+++ storeapi/common.py 2016-01-08 11:50:10 +0000
2128@@ -0,0 +1,117 @@
2129+# Copyright 2015 Canonical Ltd. This software is licensed under the
2130+# GNU General Public License version 3 (see the file LICENSE).
2131+import json
2132+import os
2133+import time
2134+from functools import wraps
2135+
2136+import requests
2137+from requests_oauthlib import OAuth1Session
2138+
2139+from storeapi.compat import urljoin
2140+from storeapi.constants import MYAPPS_API_ROOT_URL
2141+
2142+
2143+def get_oauth_session(config):
2144+ """Return a client configured to allow oauth signed requests."""
2145+ try:
2146+ session = OAuth1Session(
2147+ config['consumer_key'],
2148+ client_secret=config['consumer_secret'],
2149+ resource_owner_key=config['token_key'],
2150+ resource_owner_secret=config['token_secret'],
2151+ signature_method='PLAINTEXT',
2152+ )
2153+ except KeyError:
2154+ session = None
2155+ return session
2156+
2157+
2158+def myapps_api_call(path, session=None, method='GET', data=None):
2159+ """Issue a request for a particular endpoint of the MyApps API."""
2160+ result = {'success': False, 'errors': [], 'data': None}
2161+ if session is not None:
2162+ client = session
2163+ else:
2164+ client = requests
2165+
2166+ root_url = os.environ.get('MYAPPS_API_ROOT_URL', MYAPPS_API_ROOT_URL)
2167+ url = urljoin(root_url, path)
2168+ if method == 'GET':
2169+ response = client.get(url)
2170+ elif method == 'POST':
2171+ response = client.post(url, data=data and json.dumps(data) or None,
2172+ headers={'Content-Type': 'application/json'})
2173+ else:
2174+ raise ValueError('Method {} not supported'.format(method))
2175+
2176+ if response.ok:
2177+ result['success'] = True
2178+ result['data'] = response.json()
2179+ else:
2180+ result['errors'] = [response.text]
2181+ return result
2182+
2183+
2184+def is_scan_completed(response):
2185+ """Return True if the response indicates the scan process completed."""
2186+ if response.ok:
2187+ return response.json().get('completed', False)
2188+ return False
2189+
2190+
2191+def retry(terminator=None, retries=3, delay=3, backoff=2, logger=None):
2192+ """Decorate a function to automatically retry calling it on failure.
2193+
2194+ Arguments:
2195+ - terminator: this should be a callable that returns a boolean;
2196+ it is used to determine if the function call was successful
2197+ and the retry loop should be stopped
2198+ - retries: an integer specifying the maximum number of retries
2199+ - delay: initial number of seconds to wait for the first retry
2200+ - backoff: exponential factor to use to adapt the delay between
2201+ subsequent retries
2202+ - logger: logging.Logger instance to use for logging
2203+
2204+ The decorated function will return as soon as any of the following
2205+ conditions are met:
2206+
2207+ 1. terminator evaluates function output as True
2208+ 2. there are no more retries left
2209+
2210+ If the terminator callable is not provided, the function will be called
2211+ exactly once and will not be retried.
2212+
2213+ """
2214+ def decorated(func):
2215+ if retries != int(retries) or retries < 0:
2216+ raise ValueError(
2217+ 'retries value must be a positive integer or zero')
2218+ if delay < 0:
2219+ raise ValueError('delay value must be positive')
2220+
2221+ if backoff != int(backoff) or backoff < 1:
2222+ raise ValueError('backoff value must be a positive integer')
2223+
2224+ @wraps(func)
2225+ def wrapped(*args, **kwargs):
2226+ retries_left, current_delay = retries, delay
2227+
2228+ result = func(*args, **kwargs)
2229+ if terminator is not None:
2230+ while not terminator(result) and retries_left > 0:
2231+ msg = "... retrying in %d seconds" % current_delay
2232+ if logger:
2233+ logger.warning(msg)
2234+
2235+ # sleep
2236+ time.sleep(current_delay)
2237+ retries_left -= 1
2238+ current_delay *= backoff
2239+
2240+ # retry
2241+ result = func(*args, **kwargs)
2242+ return result, retries_left == 0
2243+
2244+ return wrapped
2245+ return decorated
2246
2247=== added file 'storeapi/compat.py'
2248--- storeapi/compat.py 1970-01-01 00:00:00 +0000
2249+++ storeapi/compat.py 2016-01-08 11:50:10 +0000
2250@@ -0,0 +1,12 @@
2251+# Copyright 2015 Canonical Ltd. This software is licensed under the
2252+# GNU General Public License version 3 (see the file LICENSE).
2253+from __future__ import absolute_import, unicode_literals
2254+
2255+
2256+try: # pragma: no cover
2257+ from builtins import open # noqa
2258+ from urllib.parse import quote_plus, urljoin
2259+except ImportError: # pragma: no cover
2260+ from __builtin__ import open # noqa
2261+ from urllib import quote_plus # noqa
2262+ from urlparse import urljoin # noqa
2263
2264=== added file 'storeapi/constants.py'
2265--- storeapi/constants.py 1970-01-01 00:00:00 +0000
2266+++ storeapi/constants.py 2016-01-08 11:50:10 +0000
2267@@ -0,0 +1,10 @@
2268+# Copyright 2015 Canonical Ltd. This software is licensed under the
2269+# GNU General Public License version 3 (see the file LICENSE).
2270+from __future__ import absolute_import, unicode_literals
2271+
2272+
2273+CLICK_UPDOWN_UPLOAD_URL = 'https://upload.apps.ubuntu.com/'
2274+MYAPPS_API_ROOT_URL = 'https://myapps.developer.ubuntu.com/dev/api/'
2275+UBUNTU_SSO_API_ROOT_URL = 'https://login.ubuntu.com/api/v2/'
2276+SCAN_STATUS_POLL_DELAY = 5
2277+SCAN_STATUS_POLL_RETRIES = 5
2278
2279=== added file 'storeapi/info.py'
2280--- storeapi/info.py 1970-01-01 00:00:00 +0000
2281+++ storeapi/info.py 2016-01-08 11:50:10 +0000
2282@@ -0,0 +1,19 @@
2283+# -*- coding: utf-8 -*-
2284+# Copyright 2015 Canonical Ltd. This software is licensed under the
2285+# GNU General Public License version 3 (see the file LICENSE).
2286+from __future__ import absolute_import, unicode_literals
2287+
2288+from storeapi.common import myapps_api_call
2289+
2290+
2291+def get_info():
2292+ """Return information about the MyApps API.
2293+
2294+ Returned data contains information about:
2295+ - version
2296+ - department
2297+ - license
2298+ - country
2299+ - channel
2300+ """
2301+ return myapps_api_call('')
2302
2303=== added directory 'storeapi/tests'
2304=== added file 'storeapi/tests/__init__.py'
2305--- storeapi/tests/__init__.py 1970-01-01 00:00:00 +0000
2306+++ storeapi/tests/__init__.py 2016-01-08 11:50:10 +0000
2307@@ -0,0 +1,2 @@
2308+# Copyright 2015 Canonical Ltd. This software is licensed under the
2309+# GNU General Public License version 3 (see the file LICENSE).
2310
2311=== added file 'storeapi/tests/test_channels.py'
2312--- storeapi/tests/test_channels.py 1970-01-01 00:00:00 +0000
2313+++ storeapi/tests/test_channels.py 2016-01-08 11:50:10 +0000
2314@@ -0,0 +1,145 @@
2315+# -*- coding: utf-8 -*-
2316+# Copyright 2015 Canonical Ltd. This software is licensed under the
2317+# GNU General Public License version 3 (see the file LICENSE).
2318+from __future__ import absolute_import, unicode_literals
2319+import json
2320+from unittest import TestCase
2321+
2322+from mock import patch
2323+
2324+from storeapi.channels import get_channels, update_channels
2325+
2326+
2327+class ChannelsAPITestCase(TestCase):
2328+
2329+ def setUp(self):
2330+ super(ChannelsAPITestCase, self).setUp()
2331+
2332+ # setup patches
2333+ oauth_session = 'storeapi.common.get_oauth_session'
2334+ patcher = patch(oauth_session)
2335+ self.mock_get_oauth_session = patcher.start()
2336+ self.mock_session = self.mock_get_oauth_session.return_value
2337+ self.addCleanup(patcher.stop)
2338+
2339+ self.mock_get = self.mock_session.get
2340+ self.mock_post = self.mock_session.post
2341+
2342+ self.channels_data = [
2343+ {'channel': 'stable', 'current': {'revision': 2, 'version': '1'}},
2344+ {'channel': 'beta', 'current': {'revision': 4, 'version': '1.5'}},
2345+ {'channel': 'edge', 'current': None},
2346+ ]
2347+
2348+ def set_channels_get_success_response(self):
2349+ mock_response = self.mock_get.return_value
2350+ mock_response.ok = True
2351+ mock_response.json.return_value = self.channels_data
2352+
2353+ def set_channels_get_error_response(self, error_msg):
2354+ mock_response = self.mock_get.return_value
2355+ mock_response.ok = False
2356+ mock_response.text = error_msg
2357+
2358+ def set_channels_post_success_response(self):
2359+ mock_response = self.mock_post.return_value
2360+ mock_response.ok = True
2361+ mock_response.json.return_value = {
2362+ 'success': True, 'errors': [], 'channels': self.channels_data
2363+ }
2364+
2365+ def set_channels_post_failed_response(self, error_msg):
2366+ mock_response = self.mock_post.return_value
2367+ mock_response.ok = True
2368+ mock_response.json.return_value = {
2369+ 'success': True, 'errors': [error_msg],
2370+ 'channels': self.channels_data
2371+ }
2372+
2373+ def set_channels_post_error_response(self, error_msg):
2374+ mock_response = self.mock_post.return_value
2375+ mock_response.ok = False
2376+ mock_response.text = error_msg
2377+
2378+ def test_get_channels(self):
2379+ self.set_channels_get_success_response()
2380+
2381+ data = get_channels(self.mock_session, 'package.name')
2382+
2383+ expected = {
2384+ 'success': True,
2385+ 'errors': [],
2386+ 'data': self.channels_data,
2387+ }
2388+ self.assertEqual(data, expected)
2389+
2390+ def test_get_channels_with_error_response(self):
2391+ error_msg = 'some error'
2392+ self.set_channels_get_error_response(error_msg)
2393+
2394+ data = get_channels(self.mock_session, 'package.name')
2395+
2396+ expected = {
2397+ 'success': False,
2398+ 'errors': [error_msg],
2399+ 'data': None,
2400+ }
2401+ self.assertEqual(data, expected)
2402+
2403+ def test_get_channels_uses_environment_variables(self):
2404+ with patch('storeapi.common.os.environ',
2405+ {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
2406+ get_channels(self.mock_session, 'package.name')
2407+ self.mock_get.assert_called_once_with(
2408+ 'http://example.com/package-channels/package.name/')
2409+
2410+ def test_update_channels(self):
2411+ self.set_channels_post_success_response()
2412+
2413+ data = update_channels(
2414+ self.mock_session, 'package.name', {'stable': 2})
2415+
2416+ expected = {
2417+ 'success': True,
2418+ 'errors': [],
2419+ 'data': self.channels_data,
2420+ }
2421+ self.assertEqual(data, expected)
2422+
2423+ def test_update_channels_with_error_response(self):
2424+ error_msg = 'some error'
2425+ self.set_channels_post_error_response(error_msg)
2426+
2427+ data = update_channels(
2428+ self.mock_session, 'package.name', {'stable': 2})
2429+
2430+ expected = {
2431+ 'success': False,
2432+ 'errors': [error_msg],
2433+ 'data': None,
2434+ }
2435+ self.assertEqual(data, expected)
2436+
2437+ def test_update_channels_with_failed_response(self):
2438+ error_msg = 'some error'
2439+ self.set_channels_post_failed_response(error_msg)
2440+
2441+ data = update_channels(
2442+ self.mock_session, 'package.name', {'stable': 2})
2443+
2444+ expected = {
2445+ 'success': True,
2446+ 'errors': [error_msg],
2447+ 'data': self.channels_data,
2448+ }
2449+ self.assertEqual(data, expected)
2450+
2451+ def test_update_channels_uses_environment_variables(self):
2452+ with patch('storeapi.common.os.environ',
2453+ {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
2454+ update_channels(
2455+ self.mock_session, 'package.name', {'stable': 2})
2456+ self.mock_post.assert_called_once_with(
2457+ 'http://example.com/package-channels/package.name/',
2458+ data=json.dumps({'stable': 2}),
2459+ headers={'Content-Type': 'application/json'})
2460
2461=== added file 'storeapi/tests/test_common.py'
2462--- storeapi/tests/test_common.py 1970-01-01 00:00:00 +0000
2463+++ storeapi/tests/test_common.py 2016-01-08 11:50:10 +0000
2464@@ -0,0 +1,203 @@
2465+# Copyright 2015 Canonical Ltd. This software is licensed under the
2466+# GNU General Public License version 3 (see the file LICENSE).
2467+import json
2468+from unittest import TestCase
2469+
2470+import responses
2471+from mock import Mock, call, patch
2472+from requests_oauthlib import OAuth1Session
2473+
2474+from storeapi.common import (
2475+ get_oauth_session,
2476+ myapps_api_call,
2477+ retry,
2478+)
2479+
2480+
2481+class GetOAuthSessionTestCase(TestCase):
2482+
2483+ def test_get_oauth_session_when_no_config(self):
2484+ config = {}
2485+ session = get_oauth_session(config)
2486+ self.assertIsNone(session)
2487+
2488+ def test_get_oauth_session_when_partial_config(self):
2489+ config = {
2490+ 'consumer_key': 'consumer-key',
2491+ 'consumer_secret': 'consumer-secret',
2492+ }
2493+ session = get_oauth_session(config)
2494+ self.assertIsNone(session)
2495+
2496+ def test_get_oauth_session(self):
2497+ config = {
2498+ 'consumer_key': 'consumer-key',
2499+ 'consumer_secret': 'consumer-secret',
2500+ 'token_key': 'token-key',
2501+ 'token_secret': 'token-secret',
2502+ }
2503+ session = get_oauth_session(config)
2504+ self.assertIsInstance(session, OAuth1Session)
2505+ self.assertEqual(session.auth.client.client_key, 'consumer-key')
2506+ self.assertEqual(session.auth.client.client_secret, 'consumer-secret')
2507+ self.assertEqual(session.auth.client.resource_owner_key, 'token-key')
2508+ self.assertEqual(session.auth.client.resource_owner_secret,
2509+ 'token-secret')
2510+
2511+
2512+class ApiCallTestCase(TestCase):
2513+
2514+ def setUp(self):
2515+ super(ApiCallTestCase, self).setUp()
2516+ p = patch('storeapi.common.os')
2517+ mock_os = p.start()
2518+ self.addCleanup(p.stop)
2519+ mock_os.environ = {'MYAPPS_API_ROOT_URL': 'http://example.com'}
2520+
2521+ @responses.activate
2522+ def test_get_success(self):
2523+ response_data = {'response': 'value'}
2524+ responses.add(responses.GET, 'http://example.com/path',
2525+ json=response_data)
2526+
2527+ result = myapps_api_call('/path')
2528+ self.assertEqual(result, {
2529+ 'success': True,
2530+ 'data': response_data,
2531+ 'errors': [],
2532+ })
2533+
2534+ @responses.activate
2535+ def test_get_error(self):
2536+ response_data = {'response': 'error'}
2537+ responses.add(responses.GET, 'http://example.com/path',
2538+ json=response_data, status=500)
2539+
2540+ result = myapps_api_call('/path')
2541+ self.assertEqual(result, {
2542+ 'success': False,
2543+ 'data': None,
2544+ 'errors': [json.dumps(response_data)],
2545+ })
2546+
2547+ @responses.activate
2548+ def test_post_success(self):
2549+ response_data = {'response': 'value'}
2550+ responses.add(responses.POST, 'http://example.com/path',
2551+ json=response_data)
2552+
2553+ result = myapps_api_call('/path', method='POST')
2554+ self.assertEqual(result, {
2555+ 'success': True,
2556+ 'data': response_data,
2557+ 'errors': [],
2558+ })
2559+
2560+ @responses.activate
2561+ def test_post_error(self):
2562+ response_data = {'response': 'value'}
2563+ responses.add(responses.POST, 'http://example.com/path',
2564+ json=response_data, status=500)
2565+
2566+ result = myapps_api_call('/path', method='POST')
2567+ self.assertEqual(result, {
2568+ 'success': False,
2569+ 'data': None,
2570+ 'errors': [json.dumps(response_data)],
2571+ })
2572+
2573+ def test_unsupported_method(self):
2574+ self.assertRaises(ValueError, myapps_api_call, '/path', method='FOO')
2575+
2576+ def test_get_with_session(self):
2577+ session = Mock()
2578+ myapps_api_call('/path', session=session)
2579+ session.get.assert_called_once_with('http://example.com/path')
2580+
2581+ def test_post_with_session(self):
2582+ session = Mock()
2583+ myapps_api_call('/path', method='POST', session=session)
2584+ session.post.assert_called_once_with(
2585+ 'http://example.com/path',
2586+ data=None, headers={'Content-Type': 'application/json'})
2587+
2588+ @responses.activate
2589+ def test_post_with_data(self):
2590+ response_data = {'response': 'value'}
2591+ responses.add(responses.POST, 'http://example.com/path',
2592+ json=response_data)
2593+
2594+ result = myapps_api_call(
2595+ '/path', method='POST', data={'request': 'value'})
2596+ self.assertEqual(result, {
2597+ 'success': True,
2598+ 'data': response_data,
2599+ 'errors': [],
2600+ })
2601+ self.assertEqual(len(responses.calls), 1)
2602+ self.assertEqual(responses.calls[0].request.headers['Content-Type'],
2603+ 'application/json')
2604+ self.assertEqual(responses.calls[0].request.body,
2605+ json.dumps({'request': 'value'}))
2606+
2607+
2608+class RetryDecoratorTestCase(TestCase):
2609+
2610+ def target(self, *args, **kwargs):
2611+ return dict(args=args, kwargs=kwargs)
2612+
2613+ def test_retry(self):
2614+ result, aborted = retry()(self.target)()
2615+ self.assertEqual(result, dict(args=(), kwargs={}))
2616+ self.assertEqual(aborted, False)
2617+
2618+ @patch('storeapi.common.time.sleep')
2619+ def test_retry_small_backoff(self, mock_sleep):
2620+ mock_terminator = Mock()
2621+ mock_terminator.return_value = False
2622+
2623+ delay = 0.001
2624+ result, aborted = retry(mock_terminator, retries=2,
2625+ delay=delay)(self.target)()
2626+
2627+ self.assertEqual(result, dict(args=(), kwargs={}))
2628+ self.assertEqual(aborted, True)
2629+ self.assertEqual(mock_terminator.call_count, 3)
2630+ self.assertEqual(mock_sleep.mock_calls, [
2631+ call(delay),
2632+ call(delay * 2),
2633+ ])
2634+
2635+ def test_retry_abort(self):
2636+ mock_terminator = Mock()
2637+ mock_terminator.return_value = False
2638+ mock_logger = Mock()
2639+
2640+ result, aborted = retry(mock_terminator, delay=0.001, backoff=1,
2641+ logger=mock_logger)(self.target)()
2642+
2643+ self.assertEqual(result, dict(args=(), kwargs={}))
2644+ self.assertEqual(aborted, True)
2645+ self.assertEqual(mock_terminator.call_count, 4)
2646+ self.assertEqual(mock_logger.warning.call_count, 3)
2647+
2648+ def test_retry_with_invalid_retries(self):
2649+ for value in (0.1, -1):
2650+ with self.assertRaises(ValueError) as ctx:
2651+ retry(retries=value)(self.target)
2652+ self.assertEqual(
2653+ str(ctx.exception),
2654+ 'retries value must be a positive integer or zero')
2655+
2656+ def test_retry_with_negative_delay(self):
2657+ with self.assertRaises(ValueError) as ctx:
2658+ retry(delay=-1)(self.target)
2659+ self.assertEqual(str(ctx.exception),
2660+ 'delay value must be positive')
2661+
2662+ def test_retry_with_invalid_backoff(self):
2663+ for value in (-1, 0, 0.1):
2664+ with self.assertRaises(ValueError) as ctx:
2665+ retry(backoff=value)(self.target)
2666+ self.assertEqual(str(ctx.exception),
2667+ 'backoff value must be a positive integer')
2668
2669=== added file 'storeapi/tests/test_info.py'
2670--- storeapi/tests/test_info.py 1970-01-01 00:00:00 +0000
2671+++ storeapi/tests/test_info.py 2016-01-08 11:50:10 +0000
2672@@ -0,0 +1,48 @@
2673+# -*- coding: utf-8 -*-
2674+# Copyright 2015 Canonical Ltd. This software is licensed under the
2675+# GNU General Public License version 3 (see the file LICENSE).
2676+from __future__ import absolute_import, unicode_literals
2677+from unittest import TestCase
2678+
2679+from mock import patch
2680+
2681+from storeapi.info import get_info
2682+
2683+
2684+class InfoAPITestCase(TestCase):
2685+
2686+ def setUp(self):
2687+ super(InfoAPITestCase, self).setUp()
2688+
2689+ patcher = patch('storeapi.common.requests.get')
2690+ self.mock_get = patcher.start()
2691+ self.mock_response = self.mock_get.return_value
2692+ self.addCleanup(patcher.stop)
2693+
2694+ def test_get_info(self):
2695+ expected = {
2696+ 'success': True,
2697+ 'errors': [],
2698+ 'data': {'version': 1},
2699+ }
2700+ self.mock_response.ok = True
2701+ self.mock_response.json.return_value = {'version': 1}
2702+ data = get_info()
2703+ self.assertEqual(data, expected)
2704+
2705+ def test_get_info_with_error_response(self):
2706+ expected = {
2707+ 'success': False,
2708+ 'errors': ['some error'],
2709+ 'data': None,
2710+ }
2711+ self.mock_response.ok = False
2712+ self.mock_response.text = 'some error'
2713+ data = get_info()
2714+ self.assertEqual(data, expected)
2715+
2716+ def test_get_info_uses_environment_variables(self):
2717+ with patch('storeapi.common.os.environ',
2718+ {'MYAPPS_API_ROOT_URL': 'http://example.com'}):
2719+ get_info()
2720+ self.mock_get.assert_called_once_with('http://example.com')
2721
2722=== added file 'storeapi/tests/test_login.py'
2723--- storeapi/tests/test_login.py 1970-01-01 00:00:00 +0000
2724+++ storeapi/tests/test_login.py 2016-01-08 11:50:10 +0000
2725@@ -0,0 +1,120 @@
2726+# -*- coding: utf-8 -*-
2727+# Copyright 2015 Canonical Ltd. This software is licensed under the
2728+# GNU General Public License version 3 (see the file LICENSE).
2729+from __future__ import absolute_import, unicode_literals
2730+import json
2731+from unittest import TestCase
2732+
2733+from mock import patch
2734+from requests import Response
2735+
2736+from storeapi._login import login
2737+from storeapi.constants import (
2738+ UBUNTU_SSO_API_ROOT_URL,
2739+)
2740+
2741+
2742+class LoginAPITestCase(TestCase):
2743+
2744+ def setUp(self):
2745+ super(LoginAPITestCase, self).setUp()
2746+ self.email = 'user@domain.com'
2747+ self.password = 'password'
2748+ self.token_name = 'token-name'
2749+
2750+ # setup patches
2751+ mock_environ = {
2752+ 'UBUNTU_SSO_API_ROOT_URL': UBUNTU_SSO_API_ROOT_URL,
2753+ }
2754+ patcher = patch('storeapi._login.os.environ', mock_environ)
2755+ patcher.start()
2756+ self.addCleanup(patcher.stop)
2757+
2758+ patcher = patch('ssoclient.v2.http.requests.Session.request')
2759+ self.mock_request = patcher.start()
2760+ self.addCleanup(patcher.stop)
2761+ self.token_data = {
2762+ 'consumer_key': 'consumer-key',
2763+ 'consumer_secret': 'consumer-secret',
2764+ 'token_key': 'token-key',
2765+ 'token_secret': 'token-secret',
2766+ }
2767+ response = self.make_response(status_code=201, reason='CREATED',
2768+ data=self.token_data)
2769+ self.mock_request.return_value = response
2770+
2771+ def make_response(self, status_code=200, reason='OK', data=None):
2772+ data = data or {}
2773+ response = Response()
2774+ response.status_code = status_code
2775+ response.reason = reason
2776+ response._content = json.dumps(data).encode('utf-8')
2777+ return response
2778+
2779+ def assert_login_request(self, otp=None, token_name=None):
2780+ if token_name is None:
2781+ token_name = self.token_name
2782+ data = {
2783+ 'email': self.email,
2784+ 'password': self.password,
2785+ 'token_name': token_name
2786+ }
2787+ if otp is not None:
2788+ data['otp'] = otp
2789+ self.mock_request.assert_called_once_with(
2790+ 'POST', UBUNTU_SSO_API_ROOT_URL + 'tokens/oauth',
2791+ data=json.dumps(data),
2792+ json=None, headers={'Content-Type': 'application/json'}
2793+ )
2794+
2795+ def test_login_successful(self):
2796+ result = login(self.email, self.password, self.token_name)
2797+ expected = {'success': True, 'body': self.token_data}
2798+ self.assertEqual(result, expected)
2799+
2800+ def test_default_token_name(self):
2801+ result = login(self.email, self.password, self.token_name)
2802+ expected = {'success': True, 'body': self.token_data}
2803+ self.assertEqual(result, expected)
2804+ self.assert_login_request()
2805+
2806+ def test_custom_token_name(self):
2807+ result = login(self.email, self.password, token_name='my-token')
2808+ expected = {'success': True, 'body': self.token_data}
2809+ self.assertEqual(result, expected)
2810+ self.assert_login_request(token_name='my-token')
2811+
2812+ def test_login_with_otp(self):
2813+ result = login(self.email, self.password, self.token_name,
2814+ otp='123456')
2815+ expected = {'success': True, 'body': self.token_data}
2816+ self.assertEqual(result, expected)
2817+ self.assert_login_request(otp='123456')
2818+
2819+ def test_login_unsuccessful_api_exception(self):
2820+ error_data = {
2821+ 'message': 'Error during login.',
2822+ 'code': 'INVALID_CREDENTIALS',
2823+ 'extra': {},
2824+ }
2825+ response = self.make_response(
2826+ status_code=401, reason='UNAUTHORISED', data=error_data)
2827+ self.mock_request.return_value = response
2828+
2829+ result = login(self.email, self.password, self.token_name)
2830+ expected = {'success': False, 'body': error_data}
2831+ self.assertEqual(result, expected)
2832+
2833+ def test_login_unsuccessful_unexpected_error(self):
2834+ error_data = {
2835+ 'message': 'Error during login.',
2836+ 'code': 'UNEXPECTED_ERROR_CODE',
2837+ 'extra': {},
2838+ }
2839+ response = self.make_response(
2840+ status_code=401, reason='UNAUTHORISED', data=error_data)
2841+ self.mock_request.return_value = response
2842+
2843+ result = login(self.email, self.password, self.token_name)
2844+ expected = {'success': False, 'body': error_data}
2845+ self.assertEqual(result, expected)
2846
2847=== added file 'storeapi/tests/test_upload.py'
2848--- storeapi/tests/test_upload.py 1970-01-01 00:00:00 +0000
2849+++ storeapi/tests/test_upload.py 2016-01-08 11:50:10 +0000
2850@@ -0,0 +1,505 @@
2851+# Copyright 2015 Canonical Ltd. This software is licensed under the
2852+# GNU General Public License version 3 (see the file LICENSE).
2853+from __future__ import absolute_import, unicode_literals
2854+import json
2855+import os
2856+import tempfile
2857+from unittest import TestCase
2858+
2859+from mock import ANY, patch
2860+from requests import Response
2861+
2862+from storeapi._upload import (
2863+ get_upload_url,
2864+ upload_app,
2865+ upload_files,
2866+ upload,
2867+)
2868+
2869+
2870+class UploadBaseTestCase(TestCase):
2871+
2872+ def setUp(self):
2873+ super(UploadBaseTestCase, self).setUp()
2874+
2875+ # setup patches
2876+ name = 'storeapi._upload.get_oauth_session'
2877+ patcher = patch(name)
2878+ self.mock_get_oauth_session = patcher.start()
2879+ self.addCleanup(patcher.stop)
2880+
2881+ self.mock_get = self.mock_get_oauth_session.return_value.get
2882+ self.mock_post = self.mock_get_oauth_session.return_value.post
2883+
2884+ self.suffix = '_0.1_all.click'
2885+ self.binary_file = self.get_temporary_file(suffix=self.suffix)
2886+
2887+ def get_temporary_file(self, suffix='.cfg'):
2888+ return tempfile.NamedTemporaryFile(suffix=suffix)
2889+
2890+
2891+class UploadWithScanTestCase(UploadBaseTestCase):
2892+
2893+ def test_default_metadata(self):
2894+ mock_response = self.mock_post.return_value
2895+ mock_response.ok = True
2896+ mock_response.json.return_value = {
2897+ 'successful': True,
2898+ 'upload_id': 'some-valid-upload-id',
2899+ }
2900+
2901+ upload(self.binary_file.name)
2902+
2903+ data = {
2904+ 'updown_id': 'some-valid-upload-id',
2905+ 'source_uploaded': False,
2906+ 'binary_filesize': 0,
2907+ }
2908+ name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
2909+ self.mock_post.assert_called_with(
2910+ get_upload_url(name), data=data, files=[])
2911+
2912+ def test_metadata_from_file(self):
2913+ mock_response = self.mock_post.return_value
2914+ mock_response.ok = True
2915+ mock_response.json.return_value = {
2916+ 'successful': True,
2917+ 'upload_id': 'some-valid-upload-id',
2918+ }
2919+
2920+ with self.get_temporary_file() as metadata_file:
2921+ data = json.dumps({'name': 'from_file'})
2922+ metadata_file.write(data.encode('utf-8'))
2923+ metadata_file.flush()
2924+
2925+ upload(
2926+ self.binary_file.name, metadata_filename=metadata_file.name)
2927+
2928+ data = {
2929+ 'updown_id': 'some-valid-upload-id',
2930+ 'source_uploaded': False,
2931+ 'binary_filesize': 0,
2932+ 'name': 'from_file',
2933+ }
2934+ name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
2935+ self.mock_post.assert_called_with(
2936+ get_upload_url(name), data=data, files=[])
2937+
2938+ def test_override_metadata(self):
2939+ mock_response = self.mock_post.return_value
2940+ mock_response.ok = True
2941+ mock_response.json.return_value = {
2942+ 'successful': True,
2943+ 'upload_id': 'some-valid-upload-id',
2944+ }
2945+
2946+ upload(
2947+ self.binary_file.name, metadata={'name': 'overridden'})
2948+
2949+ data = {
2950+ 'updown_id': 'some-valid-upload-id',
2951+ 'source_uploaded': False,
2952+ 'binary_filesize': 0,
2953+ 'name': 'overridden',
2954+ }
2955+ name = os.path.basename(self.binary_file.name).replace(self.suffix, '')
2956+ self.mock_post.assert_called_with(
2957+ get_upload_url(name), data=data, files=[])
2958+
2959+
2960+class UploadFilesTestCase(UploadBaseTestCase):
2961+
2962+ def setUp(self):
2963+ super(UploadFilesTestCase, self).setUp()
2964+ self.binary_file = self.get_temporary_file(suffix='_0.1_all.click')
2965+
2966+ def test_upload_files(self):
2967+ mock_response = self.mock_post.return_value
2968+ mock_response.ok = True
2969+ mock_response.json.return_value = {
2970+ 'successful': True,
2971+ 'upload_id': 'some-valid-upload-id',
2972+ }
2973+
2974+ response = upload_files(self.binary_file.name)
2975+ self.assertEqual(response, {
2976+ 'success': True,
2977+ 'errors': [],
2978+ 'upload_id': 'some-valid-upload-id',
2979+ 'binary_filesize': os.path.getsize(self.binary_file.name),
2980+ 'source_uploaded': False,
2981+ })
2982+
2983+ def test_upload_files_uses_environment_variables(self):
2984+ with patch.dict(os.environ,
2985+ CLICK_UPDOWN_UPLOAD_URL='http://example.com'):
2986+ upload_url = 'http://example.com/unscanned-upload/'
2987+ upload_files(self.binary_file.name)
2988+ self.mock_post.assert_called_once_with(
2989+ upload_url, files={'binary': ANY})
2990+
2991+ def test_upload_files_with_source_upload(self):
2992+ mock_response = self.mock_post.return_value
2993+ mock_response.ok = True
2994+ mock_response.json.return_value = {
2995+ 'successful': True,
2996+ 'upload_id': 'some-valid-upload-id',
2997+ }
2998+
2999+ response = upload_files(self.binary_file.name)
3000+ self.assertEqual(response, {
3001+ 'success': True,
3002+ 'errors': [],
3003+ 'upload_id': 'some-valid-upload-id',
3004+ 'binary_filesize': os.path.getsize(self.binary_file.name),
3005+ 'source_uploaded': False,
3006+ })
3007+
3008+ def test_upload_files_with_invalid_oauth_session(self):
3009+ self.mock_get_oauth_session.return_value = None
3010+ response = upload_files(self.binary_file.name)
3011+ self.assertEqual(response, {
3012+ 'success': False,
3013+ 'errors': ['No valid credentials found.'],
3014+ })
3015+ self.assertFalse(self.mock_post.called)
3016+
3017+ def test_upload_files_error_response(self):
3018+ mock_response = self.mock_post.return_value
3019+ mock_response.ok = False
3020+ mock_response.reason = '500 INTERNAL SERVER ERROR'
3021+ mock_response.text = 'server failed'
3022+
3023+ response = upload_files(self.binary_file.name)
3024+ self.assertEqual(response, {
3025+ 'success': False,
3026+ 'errors': ['server failed'],
3027+ })
3028+
3029+ def test_upload_files_handle_malformed_response(self):
3030+ mock_response = self.mock_post.return_value
3031+ mock_response.json.return_value = {'successful': False}
3032+
3033+ response = upload_files(self.binary_file.name)
3034+ err = KeyError('upload_id')
3035+ self.assertEqual(response, {
3036+ 'success': False,
3037+ 'errors': [str(err)],
3038+ })
3039+
3040+
3041+class UploadAppTestCase(UploadBaseTestCase):
3042+
3043+ def setUp(self):
3044+ super(UploadAppTestCase, self).setUp()
3045+ self.data = {
3046+ 'upload_id': 'some-valid-upload-id',
3047+ 'binary_filesize': 123456,
3048+ 'source_uploaded': False,
3049+ }
3050+ self.package_name = 'namespace.binary'
3051+
3052+ patcher = patch.multiple(
3053+ 'storeapi._upload',
3054+ SCAN_STATUS_POLL_DELAY=0.0001)
3055+ patcher.start()
3056+ self.addCleanup(patcher.stop)
3057+
3058+ def test_upload_app_with_invalid_oauth_session(self):
3059+ self.mock_get_oauth_session.return_value = None
3060+ response = upload_app(self.package_name, self.data)
3061+ self.assertEqual(response, {
3062+ 'success': False,
3063+ 'errors': ['No valid credentials found.'],
3064+ 'application_url': '',
3065+ 'revision': None,
3066+ })
3067+
3068+ def test_upload_app_uses_environment_variables(self):
3069+ with patch.dict(os.environ,
3070+ MYAPPS_API_ROOT_URL='http://example.com'):
3071+ upload_url = ("http://example.com/click-package-upload/%s/" %
3072+ self.package_name)
3073+ data = {
3074+ 'updown_id': self.data['upload_id'],
3075+ 'binary_filesize': self.data['binary_filesize'],
3076+ 'source_uploaded': self.data['source_uploaded'],
3077+ }
3078+ upload_app(self.package_name, self.data)
3079+ self.mock_post.assert_called_once_with(
3080+ upload_url, data=data, files=[])
3081+
3082+ def test_upload_app(self):
3083+ mock_response = self.mock_post.return_value
3084+ mock_response.ok = True
3085+ mock_response.json.return_value = {
3086+ 'success': True,
3087+ 'status_url': 'http://example.com/status/'
3088+ }
3089+
3090+ mock_status_response = self.mock_get.return_value
3091+ mock_status_response.ok = True
3092+ mock_status_response.json.return_value = {
3093+ 'completed': True,
3094+ 'revision': 15,
3095+ }
3096+
3097+ response = upload_app(self.package_name, self.data)
3098+ self.assertEqual(response, {
3099+ 'success': True,
3100+ 'errors': [],
3101+ 'application_url': '',
3102+ 'revision': 15,
3103+ })
3104+
3105+ def test_upload_app_error_response(self):
3106+ mock_response = self.mock_post.return_value
3107+ mock_response.ok = False
3108+ mock_response.reason = '500 INTERNAL SERVER ERROR'
3109+ mock_response.text = 'server failure'
3110+
3111+ response = upload_app(self.package_name, self.data)
3112+ self.assertEqual(response, {
3113+ 'success': False,
3114+ 'errors': ['server failure'],
3115+ 'application_url': '',
3116+ 'revision': None,
3117+ })
3118+
3119+ def test_upload_app_handle_malformed_response(self):
3120+ mock_response = self.mock_post.return_value
3121+ mock_response.ok = True
3122+ mock_response.json.return_value = {}
3123+
3124+ response = upload_app(self.package_name, self.data)
3125+ err = KeyError('status_url')
3126+ self.assertEqual(response, {
3127+ 'success': False,
3128+ 'errors': [str(err)],
3129+ 'application_url': '',
3130+ 'revision': None,
3131+ })
3132+
3133+ def test_upload_app_with_errors_during_scan(self):
3134+ mock_response = self.mock_post.return_value
3135+ mock_response.ok = True
3136+ mock_response.json.return_value = {
3137+ 'success': True,
3138+ 'status_url': 'http://example.com/status/'
3139+ }
3140+
3141+ mock_status_response = self.mock_get.return_value
3142+ mock_status_response.ok = True
3143+ mock_status_response.json.return_value = {
3144+ 'completed': True,
3145+ 'message': 'some error',
3146+ 'application_url': 'http://example.com/myapp',
3147+ }
3148+
3149+ response = upload_app(self.package_name, self.data)
3150+ self.assertEqual(response, {
3151+ 'success': False,
3152+ 'errors': ['some error'],
3153+ 'application_url': 'http://example.com/myapp',
3154+ 'revision': None,
3155+ })
3156+
3157+ def test_upload_app_poll_status(self):
3158+ mock_response = self.mock_post.return_value
3159+ mock_response.ok = True
3160+ mock_response.return_value = {
3161+ 'success': True,
3162+ 'status_url': 'http://example.com/status/'
3163+ }
3164+
3165+ response_not_completed = Response()
3166+ response_not_completed.status_code = 200
3167+ response_not_completed.encoding = 'utf-8'
3168+ response_not_completed._content = json.dumps(
3169+ {'completed': False, 'application_url': ''}).encode('utf-8')
3170+ response_completed = Response()
3171+ response_completed.status_code = 200
3172+ response_completed.encoding = 'utf-8'
3173+ response_completed._content = json.dumps(
3174+ {'completed': True, 'revision': 14,
3175+ 'application_url': 'http://example.org'}).encode('utf-8')
3176+ self.mock_get.side_effect = [
3177+ response_not_completed,
3178+ response_not_completed,
3179+ response_completed,
3180+ ]
3181+ response = upload_app(self.package_name, self.data)
3182+ self.assertEqual(response, {
3183+ 'success': True,
3184+ 'errors': [],
3185+ 'application_url': 'http://example.org',
3186+ 'revision': 14,
3187+ })
3188+ self.assertEqual(self.mock_get.call_count, 3)
3189+
3190+ def test_upload_app_ignore_non_ok_responses(self):
3191+ mock_response = self.mock_post.return_value
3192+ mock_response.ok = True
3193+ mock_response.return_value = {
3194+ 'success': True,
3195+ 'status_url': 'http://example.com/status/',
3196+ }
3197+
3198+ ok_response = Response()
3199+ ok_response.status_code = 200
3200+ ok_response.encoding = 'utf-8'
3201+ ok_response._content = json.dumps(
3202+ {'completed': True, 'revision': 14}).encode('utf-8')
3203+ nok_response = Response()
3204+ nok_response.status_code = 503
3205+
3206+ self.mock_get.side_effect = [nok_response, nok_response, ok_response]
3207+ response = upload_app(self.package_name, self.data)
3208+ self.assertEqual(response, {
3209+ 'success': True,
3210+ 'errors': [],
3211+ 'application_url': '',
3212+ 'revision': 14,
3213+ })
3214+ self.assertEqual(self.mock_get.call_count, 3)
3215+
3216+ def test_upload_app_abort_polling(self):
3217+ mock_response = self.mock_post.return_value
3218+ mock_response.ok = True
3219+ mock_response.json.return_value = {
3220+ 'success': True,
3221+ 'status_url': 'http://example.com/status/',
3222+ 'web_status_url': 'http://example.com/status-web/',
3223+ }
3224+
3225+ mock_status_response = self.mock_get.return_value
3226+ mock_status_response.ok = True
3227+ mock_status_response.json.return_value = {
3228+ 'completed': False
3229+ }
3230+ response = upload_app(self.package_name, self.data)
3231+ self.assertEqual(response, {
3232+ 'success': False,
3233+ 'errors': [
3234+ 'Package scan took too long.',
3235+ 'Please check the status later at: '
3236+ 'http://example.com/status-web/.',
3237+ ],
3238+ 'application_url': '',
3239+ 'revision': None,
3240+ })
3241+
3242+ def test_upload_app_abort_polling_without_web_status_url(self):
3243+ mock_response = self.mock_post.return_value
3244+ mock_response.ok = True
3245+ mock_response.json.return_value = {
3246+ 'success': True,
3247+ 'status_url': 'http://example.com/status/',
3248+ }
3249+
3250+ mock_status_response = self.mock_get.return_value
3251+ mock_status_response.ok = True
3252+ mock_status_response.json.return_value = {
3253+ 'completed': False
3254+ }
3255+ response = upload_app(self.package_name, self.data)
3256+ self.assertEqual(response, {
3257+ 'success': False,
3258+ 'errors': [
3259+ 'Package scan took too long.',
3260+ ],
3261+ 'application_url': '',
3262+ 'revision': None,
3263+ })
3264+
3265+ def test_upload_app_with_metadata(self):
3266+ upload_app(self.package_name, self.data, metadata={
3267+ 'changelog': 'some changes', 'tagline': 'a tagline'})
3268+ self.mock_post.assert_called_once_with(
3269+ ANY,
3270+ data={
3271+ 'updown_id': self.data['upload_id'],
3272+ 'binary_filesize': self.data['binary_filesize'],
3273+ 'source_uploaded': self.data['source_uploaded'],
3274+ 'changelog': 'some changes',
3275+ 'tagline': 'a tagline',
3276+ },
3277+ files=[],
3278+ )
3279+
3280+ def test_upload_app_ignore_special_attributes_in_metadata(self):
3281+ upload_app(
3282+ self.package_name,
3283+ self.data, metadata={
3284+ 'changelog': 'some changes',
3285+ 'tagline': 'a tagline',
3286+ 'upload_id': 'my-own-id',
3287+ 'binary_filesize': 0,
3288+ 'source_uploaded': False,
3289+ })
3290+ self.mock_post.assert_called_once_with(
3291+ ANY,
3292+ data={
3293+ 'updown_id': self.data['upload_id'],
3294+ 'binary_filesize': self.data['binary_filesize'],
3295+ 'source_uploaded': self.data['source_uploaded'],
3296+ 'changelog': 'some changes',
3297+ 'tagline': 'a tagline',
3298+ },
3299+ files=[],
3300+ )
3301+
3302+ @patch('storeapi._upload.open')
3303+ def test_upload_app_with_icon(self, mock_open):
3304+ with tempfile.NamedTemporaryFile() as icon:
3305+ mock_open.return_value = icon
3306+
3307+ upload_app(
3308+ self.package_name, self.data,
3309+ metadata={
3310+ 'icon_256': icon.name,
3311+ }
3312+ )
3313+ self.mock_post.assert_called_once_with(
3314+ ANY,
3315+ data={
3316+ 'updown_id': self.data['upload_id'],
3317+ 'binary_filesize': self.data['binary_filesize'],
3318+ 'source_uploaded': self.data['source_uploaded'],
3319+ },
3320+ files=[
3321+ ('icon_256', icon),
3322+ ],
3323+ )
3324+
3325+ @patch('storeapi._upload.open')
3326+ def test_upload_app_with_screenshots(self, mock_open):
3327+ screenshot1 = tempfile.NamedTemporaryFile()
3328+ screenshot2 = tempfile.NamedTemporaryFile()
3329+ mock_open.side_effect = [screenshot1, screenshot2]
3330+
3331+ upload_app(
3332+ self.package_name, self.data,
3333+ metadata={
3334+ 'screenshots': [screenshot1.name, screenshot2.name],
3335+ }
3336+ )
3337+ self.mock_post.assert_called_once_with(
3338+ ANY,
3339+ data={
3340+ 'updown_id': self.data['upload_id'],
3341+ 'binary_filesize': self.data['binary_filesize'],
3342+ 'source_uploaded': self.data['source_uploaded'],
3343+ },
3344+ files=[
3345+ ('screenshots', screenshot1),
3346+ ('screenshots', screenshot2),
3347+ ],
3348+ )
3349+
3350+ def test_get_upload_url(self):
3351+ with patch.dict(os.environ,
3352+ MYAPPS_API_ROOT_URL='http://example.com'):
3353+ upload_url = "http://example.com/click-package-upload/app.dev/"
3354+ url = get_upload_url('app.dev')
3355+ self.assertEqual(url, upload_url)
3356
3357=== added file 'tests.py'
3358--- tests.py 1970-01-01 00:00:00 +0000
3359+++ tests.py 2016-01-08 11:50:10 +0000
3360@@ -0,0 +1,9 @@
3361+from unittest import TestLoader, TestSuite
3362+
3363+
3364+def load_tests(loader, tests, pattern):
3365+ suites = [
3366+ TestLoader().discover('click_toolbelt', top_level_dir='.'),
3367+ TestLoader().discover('storeapi', top_level_dir='.'),
3368+ ]
3369+ return TestSuite(suites)

Subscribers

People subscribed via source and target branches