Completed
Pull Request — develop (#344)
by Gonzalo
01:48
created

Binstar   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 492
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 492
rs 4.5454
wmc 59

28 Methods

Rating   Name   Duplication   Size   Complexity  
B user_packages() 0 43 6
A release() 0 12 1
B _authenticate() 0 39 1
A package_remove_collaborator() 0 5 1
F _check_response() 0 29 9
A krb_authenticate() 0 7 2
A package() 0 11 1
A list_scopes() 0 5 1
B add_package() 0 33 1
A session() 0 3 1
A authentication() 0 8 1
A package_add_collaborator() 0 5 1
A authentication_type() 0 9 2
A package_collaborators() 0 6 1
A authenticate() 0 2 1
A authentications() 0 9 1
A remove_package() 0 7 1
A __init__() 0 21 3
A user() 0 16 2
A all_packages() 0 8 1
A remove_authentication() 0 14 3
A remove_release() 0 12 1
B add_release() 0 24 1
B download() 0 36 5
A distribution() 0 7 1
A search() 0 5 1
A remove_dist() 0 12 3
B upload() 0 61 6

How to fix   Complexity   

Complex Class

Complex classes like Binstar often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
from __future__ import unicode_literals
2
import base64
3
import collections
4
import json
5
import os
6
import requests
7
import warnings
8
9
# For backwards compatibility
10
from .errors import *
11
from . import errors
12
from .requests_ext import stream_multipart, NullAuth
13
14
from .utils import compute_hash, jencode, pv
15
from .utils.http_codes import STATUS_CODES
16
17
from .mixins.organizations import OrgMixin
18
from .mixins.channels import ChannelsMixin
19
from .mixins.package import PackageMixin
20
21
import logging
22
import platform
23
24
log = logging.getLogger('binstar')
25
26
from ._version import get_versions
27
28
__version__ = get_versions()['version']
29
del get_versions
30
31
32
class Binstar(OrgMixin, ChannelsMixin, PackageMixin):
33
    '''
34
    An object that represents interfaces with the Anaconda Cloud restful API.
35
36
    :param token: a token generated by Binstar.authenticate or None for
37
                  an anonymous user.
38
    '''
39
40
    def __init__(self, token=None, domain='https://api.anaconda.org', verify=True, **kwargs):
41
42
        self._session = requests.Session()
43
        self._session.headers['x-binstar-api-version'] = __version__
44
        self.session.verify = verify
45
        self.session.auth = NullAuth()
46
        self.token = token
47
48
        user_agent = 'Anaconda-Client/{} (+https://anaconda.org)'.format(__version__)
49
        self._session.headers.update({
50
            'User-Agent': user_agent,
51
            'Content-Type':'application/json',
52
            'Accept': 'application/json',
53
        })
54
55
        if token:
56
            self._session.headers.update({'Authorization': 'token {}'.format(token)})
57
58
        if domain.endswith('/'):
59
            domain = domain[:-1]
60
        self.domain = domain
61
62
    @property
63
    def session(self):
64
        return self._session
65
66
    def authentication_type(self):
67
        url = '%s/authentication-type' % self.domain
68
        res = self.session.get(url)
69
        try:
70
            self._check_response(res)
71
            res = res.json()
72
            return res['authentication_type']
73
        except BinstarError:
74
            return 'password'
75
76
    def krb_authenticate(self, *args, **kwargs):
77
        try:
78
            from requests_kerberos import HTTPKerberosAuth
79
            return self._authenticate(HTTPKerberosAuth(), *args, **kwargs)
80
        except ImportError:
81
            raise BinstarError(
82
                'Kerberos authentication requires the requests-kerberos '
83
                'package to be installed:\n'
84
                '    conda install requests-kerberos\n'
85
                'or: \n'
86
                '    pip install requests-kerberos'
87
            )
88
89
    def authenticate(self, username, password, *args, **kwargs):
90
        return self._authenticate((username, password), *args, **kwargs)
91
92
    def _authenticate(self,
93
                     auth,
94
                     application,
95
                     application_url=None,
96
                     for_user=None,
97
                     scopes=None,
98
                     created_with=None,
99
                     max_age=None,
100
                     strength='strong',
101
                     fail_if_already_exists=False,
102
                     hostname=platform.node()):
103
        '''
104
        Use basic authentication to create an authentication token using the interface below.
105
        With this technique, a username and password need not be stored permanently, and the user can
106
        revoke access at any time.
107
108
        :param username: The users name
109
        :param password: The users password
110
        :param application: The application that is requesting access
111
        :param application_url: The application's home page
112
        :param scopes: Scopes let you specify exactly what type of access you need. Scopes limit access for the tokens.
113
        '''
114
115
        url = '%s/authentications' % (self.domain)
116
        payload = {"scopes": scopes, "note": application, "note_url": application_url,
117
                   'hostname': hostname,
118
                   'user': for_user,
119
                   'max-age': max_age,
120
                   'created_with': None,
121
                   'strength': strength,
122
                   'fail-if-exists': fail_if_already_exists}
123
124
        data, headers = jencode(payload)
125
        res = self.session.post(url, auth=auth, data=data, headers=headers)
126
        self._check_response(res)
127
        res = res.json()
128
        token = res['token']
129
        self.session.headers.update({'Authorization': 'token %s' % (token)})
130
        return token
131
132
    def list_scopes(self):
133
        url = '%s/scopes' % (self.domain)
134
        res = requests.get(url)
135
        self._check_response(res)
136
        return res.json()
137
138
    def authentication(self):
139
        '''
140
        Retrieve information on the current authentication token
141
        '''
142
        url = '%s/authentication' % (self.domain)
143
        res = self.session.get(url)
144
        self._check_response(res)
145
        return res.json()
146
147
    def authentications(self):
148
        '''
149
        Get a list of the current authentication tokens
150
        '''
151
152
        url = '%s/authentications' % (self.domain)
153
        res = self.session.get(url)
154
        self._check_response(res)
155
        return res.json()
156
157
    def remove_authentication(self, auth_name=None, organization=None):
158
        """
159
        Remove the current authentication or the one given by `auth_name`
160
        """
161
        if auth_name:
162
            if organization:
163
                url = '%s/authentications/org/%s/name/%s' % (self.domain, organization, auth_name)
164
            else:
165
                url = '%s/authentications/name/%s' % (self.domain, auth_name)
166
        else:
167
            url = '%s/authentications' % (self.domain,)
168
169
        res = self.session.delete(url)
170
        self._check_response(res, [201])
171
172
    def _check_response(self, res, allowed=[200]):
173
        api_version = res.headers.get('x-binstar-api-version', '0.2.1')
174
        if pv(api_version) > pv(__version__):
175
            msg = ('The api server is running the binstar-api version %s. you are using %s\n' % (api_version, __version__)
176
                   + 'Please update your client with pip install -U binstar or conda update binstar')
177
            warnings.warn(msg, stacklevel=4)
178
179
180
        if not res.status_code in allowed:
181
            short, long = STATUS_CODES.get(res.status_code, ('?', 'Undefined error'))
182
            msg = '%s: %s ([%s] %s -> %s)' % (short, long, res.request.method, res.request.url, res.status_code)
183
            try:
184
                data = res.json()
185
            except:
186
                pass
187
            else:
188
                msg = data.get('error', msg)
189
190
            ErrCls = errors.BinstarError
191
            if res.status_code == 401:
192
                ErrCls = errors.Unauthorized
193
            elif res.status_code == 404:
194
                ErrCls = errors.NotFound
195
            elif res.status_code == 409:
196
                ErrCls = errors.Conflict
197
            elif res.status_code >= 500:
198
                ErrCls = errors.ServerError
199
200
            raise ErrCls(msg, res.status_code)
201
202
    def user(self, login=None):
203
        '''
204
        Get user infomration.
205
206
        :param login: (optional) the login name of the user or None. If login is None
207
                      this method will return the information of the authenticated user.
208
        '''
209
        if login:
210
            url = '%s/user/%s' % (self.domain, login)
211
        else:
212
            url = '%s/user' % (self.domain)
213
214
        res = self.session.get(url, verify=self.session.verify)
215
        self._check_response(res)
216
217
        return res.json()
218
219
    def user_packages(
220
            self,
221
            login=None,
222
            platform=None,
223
            package_type=None,
224
            type_=None,
225
            access=None):
226
        '''
227
        Returns a list of packages for a given user and optionally filter
228
        by `platform`, `package_type` and `type_`.
229
230
        :param login: (optional) the login name of the user or None. If login
231
                      is None this method will return the packages for the
232
                      authenticated user.
233
        :param platform: only find packages that include files for this platform.
234
           (e.g. 'linux-64', 'osx-64', 'win-32')
235
        :param package_type: only find packages that have this kind of file
236
           (e.g. 'env', 'conda', 'pypi')
237
        :param type_: only find packages that have this conda `type`
238
           (i.e. 'app')
239
        :param access: only find packages that have this access level
240
           (e.g. 'private', 'authenticated', 'public')
241
        '''
242
        if login:
243
            url = '{0}/packages/{1}'.format(self.domain, login)
244
        else:
245
            url = '{0}/packages'.format(self.domain)
246
247
        arguments = collections.OrderedDict()
248
249
        if platform:
250
            arguments['platform'] = platform
251
        if package_type:
252
            arguments['package_type'] = package_type
253
        if type_:
254
            arguments['type'] = type_
255
        if access:
256
            arguments['access'] = access
257
258
        res = self.session.get(url, params=arguments)
259
        self._check_response(res)
260
261
        return res.json()
262
263
    def package(self, login, package_name):
264
        '''
265
        Get infomration about a specific package
266
267
        :param login: the login of the package owner
268
        :param package_name: the name of the package
269
        '''
270
        url = '%s/package/%s/%s' % (self.domain, login, package_name)
271
        res = self.session.get(url)
272
        self._check_response(res)
273
        return res.json()
274
275
    def package_add_collaborator(self, owner, package_name, collaborator):
276
        url = '%s/packages/%s/%s/collaborators/%s' % (self.domain, owner, package_name, collaborator)
277
        res = self.session.put(url)
278
        self._check_response(res, [201])
279
        return
280
281
    def package_remove_collaborator(self, owner, package_name, collaborator):
282
        url = '%s/packages/%s/%s/collaborators/%s' % (self.domain, owner, package_name, collaborator)
283
        res = self.session.delete(url)
284
        self._check_response(res, [201])
285
        return
286
287
    def package_collaborators(self, owner, package_name):
288
289
        url = '%s/packages/%s/%s/collaborators' % (self.domain, owner, package_name)
290
        res = self.session.get(url)
291
        self._check_response(res, [200])
292
        return res.json()
293
294
    def all_packages(self, modified_after=None):
295
        '''
296
        '''
297
        url = '%s/package_listing' % (self.domain)
298
        data = {'modified_after':modified_after or ''}
299
        res = self.session.get(url, data=data)
300
        self._check_response(res)
301
        return res.json()
302
303
304
    def add_package(self, login, package_name,
305
                    summary=None,
306
                    license=None,
307
                    public=True,
308
                    license_url=None,
309
                    attrs=None):
310
        '''
311
        Add a new package to a users account
312
313
        :param login: the login of the package owner
314
        :param package_name: the name of the package to be created
315
        :param package_type: A type identifyer for the package (eg. 'pypi' or 'conda', etc.)
316
        :param summary: A short summary about the package
317
        :param license: the name of the package license
318
        :param license_url: the url of the package license
319
        :param public: if true then the package will be hosted publicly
320
        :param attrs: A dictionary of extra attributes for this package
321
        '''
322
        url = '%s/package/%s/%s' % (self.domain, login, package_name)
323
324
        attrs = attrs or {}
325
        attrs['summary'] = summary
326
        attrs['license'] = {'name':license, 'url':license_url}
327
328
        payload = dict(public=bool(public),
329
                       publish=False,
330
                       public_attrs=dict(attrs or {})
331
                       )
332
333
        data, headers = jencode(payload)
334
        res = self.session.post(url, data=data, headers=headers)
335
        self._check_response(res)
336
        return res.json()
337
338
    def remove_package(self, username, package_name):
339
340
        url = '%s/package/%s/%s' % (self.domain, username, package_name)
341
342
        res = self.session.delete(url)
343
        self._check_response(res, [201])
344
        return
345
346
    def release(self, login, package_name, version):
347
        '''
348
        Get information about a specific release
349
350
        :param login: the login of the package owner
351
        :param package_name: the name of the package
352
        :param version: the name of the package
353
        '''
354
        url = '%s/release/%s/%s/%s' % (self.domain, login, package_name, version)
355
        res = self.session.get(url)
356
        self._check_response(res)
357
        return res.json()
358
359
    def remove_release(self, username, package_name, version):
360
        '''
361
        remove a release and all files under it
362
363
        :param username: the login of the package owner
364
        :param package_name: the name of the package
365
        :param version: the name of the package
366
        '''
367
        url = '%s/release/%s/%s/%s' % (self.domain, username, package_name, version)
368
        res = self.session.delete(url)
369
        self._check_response(res, [201])
370
        return
371
372
    def add_release(self, login, package_name, version, requirements, announce,
373
                    description, icon=None):
374
        '''
375
        Add a new release to a package.
376
377
        :param login: the login of the package owner
378
        :param package_name: the name of the package
379
        :param version: the version string of the release
380
        :param requirements: A dict of requirements TODO: describe
381
        :param announce: An announcement that will be posted to all package watchers
382
        :param description: A long description about the package
383
        '''
384
385
        url = '%s/release/%s/%s/%s' % (self.domain, login, package_name, version)
386
387
        payload = {'requirements': requirements,
388
                   'announce': announce,
389
                   'description': description,
390
                   'icon': icon,
391
                   }
392
        data, headers = jencode(payload)
393
        res = self.session.post(url, data=data, headers=headers)
394
        self._check_response(res)
395
        return res.json()
396
397
    def distribution(self, login, package_name, release, basename=None):
398
399
        url = '%s/dist/%s/%s/%s/%s' % (self.domain, login, package_name, release, basename)
400
401
        res = self.session.get(url)
402
        self._check_response(res)
403
        return res.json()
404
405
    def remove_dist(self, login, package_name, release, basename=None, _id=None):
406
407
        if basename:
408
            url = '%s/dist/%s/%s/%s/%s' % (self.domain, login, package_name, release, basename)
409
        elif _id:
410
            url = '%s/dist/%s/%s/%s/-/%s' % (self.domain, login, package_name, release, _id)
411
        else:
412
            raise TypeError("method remove_dist expects either 'basename' or '_id' arguments")
413
414
        res = self.session.delete(url)
415
        self._check_response(res)
416
        return res.json()
417
418
419
    def download(self, login, package_name, release, basename, md5=None):
420
        '''
421
        Dowload a package distribution
422
423
        :param login: the login of the package owner
424
        :param package_name: the name of the package
425
        :param version: the version string of the release
426
        :param basename: the basename of the distribution to download
427
        :param md5: (optional) an md5 hash of the download if given and the package has not changed
428
                    None will be returned
429
430
        :returns: a file like object or None
431
        '''
432
433
        url = '%s/download/%s/%s/%s/%s' % (self.domain, login, package_name, release, basename)
434
        if md5:
435
            headers = {'ETag':md5, }
436
        else:
437
            headers = {}
438
439
        res = self.session.get(url, headers=headers, allow_redirects=False)
440
        self._check_response(res, allowed=[200, 302, 304])
441
442
        if res.status_code == 200:
443
            # We received the content directly from anaconda.org
444
            return res
445
        elif res.status_code == 304:
446
            # The content has not changed
447
            return None
448
        elif res.status_code == 302:
449
            # Download from s3:
450
            # We need to create a new request (without using session) to avoid
451
            # sending the custom headers set on our session to S3 (which causes
452
            # a failure).
453
            res2 = requests.get(res.headers['location'], stream=True)
454
            return res2
455
456
457
    def upload(self, login, package_name, release, basename, fd, distribution_type,
458
               description='', md5=None, size=None, dependencies=None, attrs=None, channels=('main',), callback=None):
459
        '''
460
        Upload a new distribution to a package release.
461
462
        :param login: the login of the package owner
463
        :param package_name: the name of the package
464
        :param version: the version string of the release
465
        :param basename: the basename of the distribution to download
466
        :param fd: a file like object to upload
467
        :param distribution_type: pypi or conda or ipynb, etc
468
        :param description: (optional) a short description about the file
469
        :param attrs: any extra attributes about the file (eg. build=1, pyversion='2.7', os='osx')
470
471
        '''
472
        url = '%s/stage/%s/%s/%s/%s' % (self.domain, login, package_name, release, basename)
473
        if attrs is None:
474
            attrs = {}
475
        if not isinstance(attrs, dict):
476
            raise TypeError('argument attrs must be a dictionary')
477
478
        payload = dict(distribution_type=distribution_type, description=description, attrs=attrs,
479
                       dependencies=dependencies, channels=channels)
480
481
        data, headers = jencode(payload)
482
        res = self.session.post(url, data=data, headers=headers)
483
        self._check_response(res)
484
        obj = res.json()
485
486
        s3url = obj['post_url']
487
        s3data = obj['form_data']
488
489
        if md5 is None:
490
            _hexmd5, b64md5, size = compute_hash(fd, size=size)
491
        elif size is None:
492
            spos = fd.tell()
493
            fd.seek(0, os.SEEK_END)
494
            size = fd.tell() - spos
495
            fd.seek(spos)
496
497
        s3data['Content-Length'] = size
498
        s3data['Content-MD5'] = b64md5
499
500
        data_stream, headers = stream_multipart(s3data, files={'file':(basename, fd)},
501
                                                callback=callback)
502
503
        s3res = requests.post(s3url, data=data_stream, verify=self.session.verify, timeout=10 * 60 * 60, headers=headers)
504
505
        if s3res.status_code != 201:
506
            log.info(s3res.text)
507
            log.info('')
508
            log.info('')
509
            raise errors.BinstarError('Error uploading package', s3res.status_code)
510
511
        url = '%s/commit/%s/%s/%s/%s' % (self.domain, login, package_name, release, basename)
512
        payload = dict(dist_id=obj['dist_id'])
513
        data, headers = jencode(payload)
514
        res = self.session.post(url, data=data, headers=headers)
515
        self._check_response(res)
516
517
        return res.json()
518
519
    def search(self, query, package_type=None):
520
        url = '%s/search' % self.domain
521
        res = self.session.get(url, params={'name':query, 'type':package_type})
522
        self._check_response(res)
523
        return res.json()
524
525
526
from ._version import get_versions
527
__version__ = get_versions()['version']
528
del get_versions
529