1from __future__ import absolute_import, division, print_function, unicode_literals
2import sys
3
4from six import with_metaclass
5
6from discogs_client.exceptions import HTTPError
7from discogs_client.utils import parse_timestamp, update_qs, omit_none
8
9
10class SimpleFieldDescriptor(object):
11    """
12    An attribute that determines its value using the object's fetch() method.
13
14    If transform is a callable, the value will be passed through transform when
15    read. Useful for strings that should be ints, parsing timestamps, etc.
16
17    Shorthand for:
18
19        @property
20        def foo(self):
21            return self.fetch('foo')
22    """
23    def __init__(self, name, writable=False, transform=None):
24        self.name = name
25        self.writable = writable
26        self.transform = transform
27
28    def __get__(self, instance, owner):
29        if instance is None:
30            return self
31        value = instance.fetch(self.name)
32        if self.transform:
33            value = self.transform(value)
34        return value
35
36    def __set__(self, instance, value):
37        if self.writable:
38            instance.changes[self.name] = value
39            return
40        raise AttributeError("can't set attribute")
41
42
43class ObjectFieldDescriptor(object):
44    """
45    An attribute that determines its value using the object's fetch() method,
46    and passes the resulting value through an APIObject.
47
48    If optional = True, the value will be None (rather than an APIObject
49    instance) if the key is missing from the response.
50
51    If as_id = True, the value is treated as an ID for the new APIObject rather
52    than a partial dict of the APIObject.
53
54    Shorthand for:
55
56        @property
57        def baz(self):
58            return BazClass(self.client, self.fetch('baz'))
59    """
60    def __init__(self, name, class_name, optional=False, as_id=False):
61        self.name = name
62        self.class_name = class_name
63        self.optional = optional
64        self.as_id = as_id
65
66    def __get__(self, instance, owner):
67        if instance is None:
68            return self
69        wrapper_class = CLASS_MAP[self.class_name.lower()]
70        response_dict = instance.fetch(self.name)
71        if self.optional and not response_dict:
72            return None
73        if self.as_id:
74            # Response_dict wasn't really a dict. Make it so.
75            response_dict = {'id': response_dict}
76        return wrapper_class(instance.client, response_dict)
77
78    def __set__(self, instance, value):
79        raise AttributeError("can't set attribute")
80
81
82class ListFieldDescriptor(object):
83    """
84    An attribute that determines its value using the object's fetch() method,
85    and passes each item in the resulting list through an APIObject.
86
87    Shorthand for:
88
89        @property
90        def bar(self):
91            return [BarClass(self.client, d) for d in self.fetch('bar', [])]
92    """
93    def __init__(self, name, class_name):
94        self.name = name
95        self.class_name = class_name
96
97    def __get__(self, instance, owner):
98        if instance is None:
99            return self
100        wrapper_class = CLASS_MAP[self.class_name.lower()]
101        return [wrapper_class(instance.client, d) for d in instance.fetch(self.name, [])]
102
103    def __set__(self, instance, value):
104        raise AttributeError("can't set attribute")
105
106
107class ObjectCollectionDescriptor(object):
108    """
109    An attribute that determines its value by fetching a URL to a paginated
110    list of related objects, and passes each item in the resulting list through
111    an APIObject.
112
113    Shorthand for:
114
115        @property
116        def frozzes(self):
117            return PaginatedList(self.client, self.fetch('frozzes_url'), 'frozzes', FrozClass)
118    """
119    def __init__(self, name, class_name, url_key=None, list_class=None):
120        self.name = name
121        self.class_name = class_name
122
123        if url_key is None:
124            url_key = name + '_url'
125        self.url_key = url_key
126
127        if list_class is None:
128            list_class = PaginatedList
129        self.list_class = list_class
130
131    def __get__(self, instance, owner):
132        if instance is None:
133            return self
134        wrapper_class = CLASS_MAP[self.class_name.lower()]
135        return self.list_class(instance.client, instance.fetch(self.url_key), self.name, wrapper_class)
136
137    def __set__(self, instance, value):
138        raise AttributeError("can't set attribute")
139
140
141class Field(object):
142    """
143    A placeholder for a descriptor. Is transformed into a descriptor by the
144    APIObjectMeta metaclass when the APIObject classes are created.
145    """
146    _descriptor_class = None
147
148    def __init__(self, *args, **kwargs):
149        self.key = kwargs.pop('key', None)
150        self.args = args
151        self.kwargs = kwargs
152
153    def to_descriptor(self, attr_name):
154        return self._descriptor_class(self.key or attr_name, *self.args, **self.kwargs)
155
156
157class SimpleField(Field):
158    """A field that just returns the value of a given JSON key."""
159    _descriptor_class = SimpleFieldDescriptor
160
161
162class ListField(Field):
163    """A field that returns a list of APIObjects."""
164    _descriptor_class = ListFieldDescriptor
165
166
167class ObjectField(Field):
168    """A field that returns a single APIObject."""
169    _descriptor_class = ObjectFieldDescriptor
170
171
172class ObjectCollection(Field):
173    """A field that returns a paginated list of APIObjects."""
174    _descriptor_class = ObjectCollectionDescriptor
175
176
177class APIObjectMeta(type):
178    def __new__(cls, name, bases, dict_):
179        for k, v in dict_.items():
180            if isinstance(v, Field):
181                dict_[k] = v.to_descriptor(k)
182        return super(APIObjectMeta, cls).__new__(cls, name, bases, dict_)
183
184
185class APIObject(with_metaclass(APIObjectMeta, object)):
186    def repr_str(self, string):
187        if sys.version_info < (3,):
188            return string.encode('utf-8')
189        return string
190
191
192class PrimaryAPIObject(APIObject):
193    """A first-order API object that has a canonical endpoint of its own."""
194    def __init__(self, client, dict_):
195        self.data = dict_
196        self.client = client
197        self._known_invalid_keys = []
198        self.changes = {}
199
200    def __eq__(self, other):
201        if isinstance(other, self.__class__):
202            return self.id == other.id
203        return NotImplemented
204
205    def __ne__(self, other):
206        equal = self.__eq__(other)
207        return NotImplemented if equal is NotImplemented else not equal
208
209    def refresh(self):
210        if self.data.get('resource_url'):
211            data = self.client._get(self.data['resource_url'])
212            self.data.update(data)
213            self.changes = {}
214
215    def save(self):
216        if self.data.get('resource_url'):
217            # TODO: This should be PATCH
218            self.client._post(self.data['resource_url'], self.changes)
219
220            # Refresh the object, in case there were side-effects
221            self.refresh()
222
223    def delete(self):
224        if self.data.get('resource_url'):
225            self.client._delete(self.data['resource_url'])
226
227    def fetch(self, key, default=None):
228        if key in self._known_invalid_keys:
229            return default
230
231        try:
232            # First, look in the cache of pending changes
233            return self.changes[key]
234        except KeyError:
235            pass
236
237        try:
238            # Next, look in the potentially incomplete local cache
239            return self.data[key]
240        except KeyError:
241            pass
242
243        # Now refresh the object from its resource_url.
244        # The key might exist but not be in our cache.
245        self.refresh()
246
247        try:
248            return self.data[key]
249        except:
250            self._known_invalid_keys.append(key)
251            return default
252
253
254# This is terribly cheesy, but makes the client API more consistent
255class SecondaryAPIObject(APIObject):
256    """
257    An object that wraps parts of a response and doesn't have its own
258    endpoint.
259    """
260    def __init__(self, client, dict_):
261        self.client = client
262        self.data = dict_
263
264    def fetch(self, key, default=None):
265        return self.data.get(key, default)
266
267
268class BasePaginatedResponse(object):
269    """Base class for lists of objects spread across many URLs."""
270    def __init__(self, client, url):
271        self.client = client
272        self.url = url
273        self._num_pages = None
274        self._num_items = None
275        self._pages = {}
276        self._per_page = 50
277        self._list_key = 'items'
278        self._sort_key = None
279        self._sort_order = 'asc'
280        self._filters = {}
281
282    @property
283    def per_page(self):
284        return self._per_page
285
286    @per_page.setter
287    def per_page(self, value):
288        self._per_page = value
289        self._invalidate()
290
291    def _invalidate(self):
292        self._pages = {}
293        self._num_pages = None
294        self._num_items = None
295
296    def _load_pagination_info(self):
297        data = self.client._get(self._url_for_page(1))
298        self._pages[1] = [
299            self._transform(item) for item in data[self._list_key]
300        ]
301        self._num_pages = data['pagination']['pages']
302        self._num_items = data['pagination']['items']
303
304    def _url_for_page(self, page):
305        base_qs = {
306            'page': page,
307            'per_page': self._per_page,
308        }
309
310        if self._sort_key is not None:
311            base_qs.update({
312                'sort': self._sort_key,
313                'sort_order': self._sort_order,
314            })
315
316        base_qs.update(self._filters)
317
318        return update_qs(self.url, base_qs)
319
320    def sort(self, key, order='asc'):
321        if order not in ('asc', 'desc'):
322            raise ValueError("Order must be one of 'asc', 'desc'")
323        self._sort_key = key
324        self._sort_order = order
325        self._invalidate()
326        return self
327
328    def filter(self, **kwargs):
329        self._filters = kwargs
330        self._invalidate()
331        return self
332
333    @property
334    def pages(self):
335        if self._num_pages is None:
336            self._load_pagination_info()
337        return self._num_pages
338
339    @property
340    def count(self):
341        if self._num_items is None:
342            self._load_pagination_info()
343        return self._num_items
344
345    def page(self, index):
346        if index not in self._pages:
347            data = self.client._get(self._url_for_page(index))
348            self._pages[index] = [
349                self._transform(item) for item in data[self._list_key]
350            ]
351        return self._pages[index]
352
353    def _transform(self, item):
354        return item
355
356    def __getitem__(self, index):
357        page_index = index // self.per_page + 1
358        offset = index % self.per_page
359
360        try:
361            page = self.page(page_index)
362        except HTTPError as e:
363            if e.status_code == 404:
364                raise IndexError(e.msg)
365            else:
366                raise
367
368        return page[offset]
369
370    def __len__(self):
371        return self.count
372
373    def __iter__(self):
374        for i in range(1, self.pages + 1):
375            page = self.page(i)
376            for item in page:
377                yield item
378
379
380class PaginatedList(BasePaginatedResponse):
381    """A paginated list of objects of a particular class."""
382    def __init__(self, client, url, key, class_):
383        super(PaginatedList, self).__init__(client, url)
384        self._list_key = key
385        self.class_ = class_
386
387    def _transform(self, item):
388        return self.class_(self.client, item)
389
390
391class Wantlist(PaginatedList):
392    def add(self, release, notes=None, notes_public=None, rating=None):
393        release_id = release.id if isinstance(release, Release) else release
394        data = {
395            'release_id': str(release_id),
396            'notes': notes,
397            'notes_public': notes_public,
398            'rating': rating,
399        }
400        self.client._put(self.url + '/' + str(release_id), omit_none(data))
401        self._invalidate()
402
403    def remove(self, release):
404        release_id = release.id if isinstance(release, Release) else release
405        self.client._delete(self.url + '/' + str(release_id))
406        self._invalidate()
407
408
409class OrderMessagesList(PaginatedList):
410    def add(self, message=None, status=None, email_buyer=True, email_seller=False):
411        data = {
412            'message': message,
413            'status': status,
414            'email_buyer': email_buyer,
415            'email_seller': email_seller,
416        }
417        self.client._post(self.url, omit_none(data))
418        self._invalidate()
419
420
421class MixedPaginatedList(BasePaginatedResponse):
422    """A paginated list of objects identified by their type parameter."""
423    def __init__(self, client, url, key):
424        super(MixedPaginatedList, self).__init__(client, url)
425        self._list_key = key
426
427    def _transform(self, item):
428        # In some cases, we want to map the 'title' key we get back in search
429        # results to 'name'. This way, you can repr() a page of search results
430        # without making 50 requests.
431        if item['type'] in ('label', 'artist'):
432            item['name'] = item['title']
433
434        return CLASS_MAP[item['type']](self.client, item)
435
436
437class Artist(PrimaryAPIObject):
438    id = SimpleField()
439    name = SimpleField()
440    real_name = SimpleField(key='realname')
441    images = SimpleField()
442    profile = SimpleField()
443    data_quality = SimpleField()
444    name_variations = SimpleField(key='namevariations')
445    url = SimpleField('uri')
446    urls = SimpleField()
447    aliases = ListField('Artist')
448    members = ListField('Artist')
449    groups = ListField('Artist')
450
451    def __init__(self, client, dict_):
452        super(Artist, self).__init__(client, dict_)
453        self.data['resource_url'] = '{0}/artists/{1}'.format(client._base_url, dict_['id'])
454
455    @property
456    def releases(self):
457        return MixedPaginatedList(self.client, self.fetch('releases_url'), 'releases')
458
459    def __repr__(self):
460        return self.repr_str('<Artist {0!r} {1!r}>'.format(self.id, self.name))
461
462
463class Release(PrimaryAPIObject):
464    id = SimpleField()
465    title = SimpleField()
466    year = SimpleField()
467    thumb = SimpleField()
468    data_quality = SimpleField()
469    status = SimpleField()
470    genres = SimpleField()
471    images = SimpleField()
472    country = SimpleField()
473    notes = SimpleField()
474    formats = SimpleField()
475    styles = SimpleField()
476    url = SimpleField('uri')
477    videos = ListField('Video')
478    tracklist = ListField('Track')
479    artists = ListField('Artist')
480    credits = ListField('Artist', key='extraartists')
481    labels = ListField('Label')
482    companies = ListField('Label')
483
484    def __init__(self, client, dict_):
485        super(Release, self).__init__(client, dict_)
486        self.data['resource_url'] = '{0}/releases/{1}'.format(client._base_url, dict_['id'])
487
488    @property
489    def master(self):
490        master_id = self.fetch('master_id')
491        if master_id:
492            return Master(self.client, {'id': master_id})
493        else:
494            return None
495
496    def __repr__(self):
497        return self.repr_str('<Release {0!r} {1!r}>'.format(self.id, self.title))
498
499
500class Master(PrimaryAPIObject):
501    id = SimpleField()
502    title = SimpleField()
503    data_quality = SimpleField()
504    styles = SimpleField()
505    genres = SimpleField()
506    images = SimpleField()
507    url = SimpleField('uri')
508    videos = ListField('Video')
509    tracklist = ListField('Track')
510    main_release = ObjectField('Release', as_id=True)
511    versions = ObjectCollection('Release')
512
513    def __init__(self, client, dict_):
514        super(Master, self).__init__(client, dict_)
515        self.data['resource_url'] = '{0}/masters/{1}'.format(client._base_url, dict_['id'])
516
517    def __repr__(self):
518        return self.repr_str('<Master {0!r} {1!r}>'.format(self.id, self.title))
519
520
521class Label(PrimaryAPIObject):
522    id = SimpleField()
523    name = SimpleField()
524    profile = SimpleField()
525    urls = SimpleField()
526    images = SimpleField()
527    contact_info = SimpleField()
528    data_quality = SimpleField()
529    url = SimpleField('uri')
530    sublabels = ListField('Label')
531    parent_label = ObjectField('Label', optional=True)
532    releases = ObjectCollection('Release')
533
534    def __init__(self, client, dict_):
535        super(Label, self).__init__(client, dict_)
536        self.data['resource_url'] = '{0}/labels/{1}'.format(client._base_url, dict_['id'])
537
538    def __repr__(self):
539        return self.repr_str('<Label {0!r} {1!r}>'.format(self.id, self.name))
540
541
542class User(PrimaryAPIObject):
543    id = SimpleField()
544    username = SimpleField()
545    releases_contributed = SimpleField()
546    num_collection = SimpleField()
547    num_wantlist = SimpleField()
548    num_lists = SimpleField()
549    rank = SimpleField()
550    rating_avg = SimpleField()
551    url = SimpleField('uri')
552    name = SimpleField(writable=True)
553    profile = SimpleField(writable=True)
554    location = SimpleField(writable=True)
555    home_page = SimpleField(writable=True)
556    registered = SimpleField(transform=parse_timestamp)
557    inventory = ObjectCollection('Listing', key='listings', url_key='inventory_url')
558    wantlist = ObjectCollection('WantlistItem', key='wants', url_key='wantlist_url', list_class=Wantlist)
559
560    def __init__(self, client, dict_):
561        super(User, self).__init__(client, dict_)
562        self.data['resource_url'] = '{0}/users/{1}'.format(client._base_url, dict_['username'])
563
564    @property
565    def orders(self):
566        return PaginatedList(self.client, self.client._base_url + '/marketplace/orders', 'orders', Order)
567
568    @property
569    def collection_folders(self):
570        resp = self.client._get(self.fetch('collection_folders_url'))
571        return [CollectionFolder(self.client, d) for d in resp['folders']]
572
573    def __repr__(self):
574        return self.repr_str('<User {0!r} {1!r}>'.format(self.id, self.username))
575
576
577class WantlistItem(PrimaryAPIObject):
578    id = SimpleField()
579    rating = SimpleField(writable=True)
580    notes = SimpleField(writable=True)
581    notes_public = SimpleField(writable=True)
582    release = ObjectField('Release', key='basic_information')
583
584    def __init__(self, client, dict_):
585        super(WantlistItem, self).__init__(client, dict_)
586
587    def __repr__(self):
588        return self.repr_str('<WantlistItem {0!r} {1!r}>'.format(self.id, self.release.title))
589
590
591# TODO: folder_id should be a Folder object; needs folder_url
592# TODO: notes should be first-order (somehow); needs resource_url
593class CollectionItemInstance(PrimaryAPIObject):
594    id = SimpleField()
595    rating = SimpleField()
596    folder_id = SimpleField()
597    notes = SimpleField()
598    release = ObjectField('Release', key='basic_information')
599
600    def __init__(self, client, dict_):
601        super(CollectionItemInstance, self).__init__(client, dict_)
602
603    def __repr__(self):
604        return self.repr_str('<CollectionItemInstance {0!r} {1!r}>'.format(self.id, self.release.title))
605
606
607class CollectionFolder(PrimaryAPIObject):
608    id = SimpleField()
609    name = SimpleField()
610    count = SimpleField()
611
612    def __init__(self, client, dict_):
613        super(CollectionFolder, self).__init__(client, dict_)
614
615    @property
616    def releases(self):
617        # TODO: Needs releases_url
618        return PaginatedList(self.client, self.fetch('resource_url') + '/releases', 'releases', CollectionItemInstance)
619
620    def __repr__(self):
621        return self.repr_str('<CollectionFolder {0!r} {1!r}>'.format(self.id, self.name))
622
623
624class Listing(PrimaryAPIObject):
625    id = SimpleField()
626    status = SimpleField()
627    allow_offers = SimpleField()
628    condition = SimpleField()
629    sleeve_condition = SimpleField()
630    ships_from = SimpleField()
631    comments = SimpleField()
632    audio = SimpleField()
633    url = SimpleField('uri')
634    price = ObjectField('Price')
635    release = ObjectField('Release')
636    seller = ObjectField('User')
637    posted = SimpleField(transform=parse_timestamp)
638
639    def __init__(self, client, dict_):
640        super(Listing, self).__init__(client, dict_)
641        self.data['resource_url'] = '{0}/marketplace/listings/{1}'.format(client._base_url, dict_['id'])
642
643    def __repr__(self):
644        return self.repr_str('<Listing {0!r} {1!r}>'.format(self.id, self.release.data['description']))
645
646
647class Order(PrimaryAPIObject):
648    id = SimpleField()
649    next_status = SimpleField()
650    shipping_address = SimpleField()
651    additional_instructions = SimpleField()
652    url = SimpleField('uri')
653    status = SimpleField(writable=True)
654    fee = ObjectField('Price')
655    buyer = ObjectField('User')
656    seller = ObjectField('User')
657    created = SimpleField(transform=parse_timestamp)
658    last_activity = SimpleField(transform=parse_timestamp)
659    messages = ObjectCollection('OrderMessage', list_class=OrderMessagesList)
660    items = ListField('Listing')
661
662    def __init__(self, client, dict_):
663        super(Order, self).__init__(client, dict_)
664        self.data['resource_url'] = '{0}/marketplace/orders/{1}'.format(client._base_url, dict_['id'])
665
666    # Setting shipping is a little weird -- you can't change the
667    # currency, and you use the 'shipping' key instead of 'value'
668    @property
669    def shipping(self):
670        return Price(self.client, self.fetch('shipping'))
671
672    @shipping.setter
673    def shipping(self, value):
674        self.changes['shipping'] = value
675
676    def __repr__(self):
677        return self.repr_str('<Order {0!r}>'.format(self.id))
678
679
680class OrderMessage(SecondaryAPIObject):
681    subject = SimpleField()
682    message = SimpleField()
683    to = ObjectField('User')
684    order = ObjectField('Order')
685    timestamp = SimpleField(transform=parse_timestamp)
686
687    def __repr__(self):
688        return self.repr_str('<OrderMessage to:{0!r}>'.format(self.to.username))
689
690
691class Track(SecondaryAPIObject):
692    duration = SimpleField()
693    position = SimpleField()
694    title = SimpleField()
695    artists = ListField('Artist')
696    credits = ListField('Artist', key='extraartists')
697
698    def __repr__(self):
699        return self.repr_str('<Track {0!r} {1!r}>'.format(self.position, self.title))
700
701
702class Price(SecondaryAPIObject):
703    currency = SimpleField()
704    value = SimpleField()
705
706    def __repr__(self):
707        return self.repr_str('<Price {0!r} {1!r}>'.format(self.value, self.currency))
708
709
710class Video(SecondaryAPIObject):
711    duration = SimpleField()
712    embed = SimpleField()
713    title = SimpleField()
714    description = SimpleField()
715    url = SimpleField('uri')
716
717    def __repr__(self):
718        return self.repr_str('<Video {0!r}>'.format(self.title))
719
720CLASS_MAP = {
721    'artist': Artist,
722    'release': Release,
723    'master': Master,
724    'label': Label,
725    'price': Price,
726    'video': Video,
727    'track': Track,
728    'user': User,
729    'order': Order,
730    'listing': Listing,
731    'wantlistitem': WantlistItem,
732    'ordermessage': OrderMessage,
733}
734