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