1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 import urllib
22 import urllib2
23
24 import logging
25 import simplejson
26 import cgi
27 from scapi.MultipartPostHandler import MultipartPostHandler
28 from inspect import isclass
29 import urlparse
30 from scapi.authentication import BasicAuthenticator
31 from scapi.util import (
32 escape,
33 MultiDict,
34 )
35
36 logging.basicConfig()
37 logger = logging.getLogger(__name__)
38
39 USE_PROXY = False
40 """
41 Something like http://127.0.0.1:10000/
42 """
43 PROXY = ''
44
45
46
47 """
48 The url Soundcould offers to obtain request-tokens
49 """
50 REQUEST_TOKEN_URL = 'http://api.soundcloud.com/oauth/request_token'
51 """
52 The url Soundcould offers to exchange access-tokens for request-tokens.
53 """
54 ACCESS_TOKEN_URL = 'http://api.soundcloud.com/oauth/access_token'
55 """
56 The url Soundcould offers to make users authorize a concrete request token.
57 """
58 AUTHORIZATION_URL = 'http://api.soundcloud.com/oauth/authorize'
59
60 __all__ = ['SoundCloudAPI', 'USE_PROXY', 'PROXY', 'REQUEST_TOKEN_URL', 'ACCESS_TOKEN_URL', 'AUTHORIZATION_URL']
65
67
69 self._message = message
70 Exception.__init__(self)
71
73 res = Exception.__repr__(self)
74 res += "\n"
75 res += "-" * 10
76 res += "\nmessage:\n\n"
77 res += self._message
78 return res
79
80 -class UnknownContentType(Exception):
81 - def __init__(self, msg):
82 Exception.__init__(self)
83 self._msg = msg
84
86 return self.__class__.__name__ + ":" + self._msg
87
90
93 """
94 The ApiConnector holds all the data necessary to authenticate against
95 the soundcloud-api. You can instantiate several connectors if you like, but usually one
96 should be sufficient.
97 """
98
99 """
100 SoundClound imposes a maximum on the number of returned items. This value is that
101 maximum.
102 """
103 LIST_LIMIT = 50
104
105 """
106 The query-parameter that is used to request results beginning from a certain offset.
107 """
108 LIST_OFFSET_PARAMETER = 'offset'
109 """
110 The query-parameter that is used to request results being limited to a certain amount.
111
112 Currently this is of no use and just for completeness sake.
113 """
114 LIST_LIMIT_PARAMETER = 'limit'
115
116 - def __init__(self, host, user=None, password=None, authenticator=None, base="", collapse_scope=True):
117 """
118 Constructor for the API-Singleton. Use it once with parameters, and then the
119 subsequent calls internal to the API will work.
120
121 @type host: str
122 @param host: the host to connect to, e.g. "api.soundcloud.com". If a port is needed, use
123 "api.soundcloud.com:1234"
124 @type user: str
125 @param user: if given, the username for basic HTTP authentication
126 @type password: str
127 @param password: if the user is given, you have to give a password as well
128 @type authenticator: OAuthAuthenticator | BasicAuthenticator
129 @param authenticator: the authenticator to use, see L{scapi.authentication}
130 """
131 self.host = host
132 if authenticator is not None:
133 self.authenticator = authenticator
134 elif user is not None and password is not None:
135 self.authenticator = BasicAuthenticator(user, password)
136 self._base = base
137 self.collapse_scope = collapse_scope
138
140 """
141 This method will take a method that has been part of a redirect of some sort
142 and see if it's valid, which means that it's located beneath our base.
143 If yes, we return it normalized without that very base.
144 """
145 _, _, path, _, _, _ = urlparse.urlparse(method)
146 if path.startswith("/"):
147 path = path[1:]
148
149
150 if self._base == "":
151 return path
152 if path.startswith(self._base):
153 return path[len(self._base)-1:]
154 raise InvalidMethodException("Not a valid API method: %s" % method)
155
156
157
159 """
160 Helper-function for a registered consumer to obtain a request token, as
161 used by oauth.
162
163 Use it like this:
164
165 >>> oauth_authenticator = scapi.authentication.OAuthAuthenticator(CONSUMER,
166 CONSUMER_SECRET,
167 None,
168 None)
169
170 >>> sca = scapi.ApiConnector(host=API_HOST, authenticator=oauth_authenticator)
171 >>> token, secret = sca.fetch_request_token()
172 >>> authorization_url = sca.get_request_token_authorization_url(token)
173
174 Please note the None passed as token & secret to the authenticator.
175 """
176 if url is None:
177 url = REQUEST_TOKEN_URL
178 req = urllib2.Request(url)
179 self.authenticator.augment_request(req, None, oauth_callback=oauth_callback, oauth_verifier=oauth_verifier)
180 handlers = []
181 if USE_PROXY:
182 handlers.append(urllib2.ProxyHandler({'http' : PROXY}))
183 opener = urllib2.build_opener(*handlers)
184 handle = opener.open(req, None)
185 info = handle.info()
186 content = handle.read()
187 params = cgi.parse_qs(content, keep_blank_values=False)
188 key = params['oauth_token'][0]
189 secret = params['oauth_token_secret'][0]
190 return key, secret
191
192
194 """
195 Helper-function for a registered consumer to exchange an access token for
196 a request token.
197
198 Use it like this:
199
200 >>> oauth_authenticator = scapi.authentication.OAuthAuthenticator(CONSUMER,
201 CONSUMER_SECRET,
202 request_token,
203 request_token_secret)
204
205 >>> sca = scapi.ApiConnector(host=API_HOST, authenticator=oauth_authenticator)
206 >>> token, secret = sca.fetch_access_token()
207
208 Please note the values passed as token & secret to the authenticator.
209 """
210 return self.fetch_request_token(ACCESS_TOKEN_URL, oauth_verifier=oauth_verifier)
211
212
214 """
215 Simple helper function to generate the url needed
216 to ask a user for request token authorization.
217
218 See also L{fetch_request_token}.
219
220 Possible usage:
221
222 >>> import webbrowser
223 >>> sca = scapi.ApiConnector()
224 >>> authorization_url = sca.get_request_token_authorization_url(token)
225 >>> webbrowser.open(authorization_url)
226 """
227 return "%s?oauth_token=%s" % (AUTHORIZATION_URL, token)
228
232 """
233 A urllib2-Handler to deal with the redirects the RESTful API of SC uses.
234 """
235 alternate_method = None
236
238 """
239 In case of return-code 303 (See-other), we have to store the location we got
240 because that will determine the actual type of resource returned.
241 """
242 self.alternate_method = hdrs['location']
243
244
245 new_url = self.alternate_method
246
247
248
249
250
251 req = req.recreate_request(new_url)
252 return urllib2.HTTPRedirectHandler.http_error_303(self, req, fp, code, msg, hdrs)
253
255 """
256 We fake a 201 being a 303 so that our redirection-scheme takes place
257 for the 201 the API throws in case we created something. If the location is
258 not available though, that means that whatever we created has succeded - without
259 being a named resource. Assigning an asset to a track is an example of such
260 case.
261 """
262 if 'location' not in hdrs:
263 raise NoResultFromRequest()
264 return self.http_error_303(req, fp, 303, msg, hdrs)
265
267 """
268 The basic means to query and create resources. The Scope uses the L{ApiConnector} to
269 create the proper URIs for querying or creating resources.
270
271 For accessing resources from the root level, you explcitly create a Scope and pass it
272 an L{ApiConnector}-instance. Then you can query it
273 or create new resources like this:
274
275 >>> connector = scapi.ApiConnector(host='host', user='user', password='password') # initialize the API
276 >>> scope = scapi.Scope(connector) # get the root scope
277 >>> users = list(scope.users())
278 [<scapi.User object at 0x12345>, ...]
279
280 Please not that all resources that are lists are returned as B{generator}. So you need
281 to either iterate over them, or call list(resources) on them.
282
283 When accessing resources that belong to another resource, like contacts of a user, you access
284 the parent's resource scope implicitly through the resource instance like this:
285
286 >>> user = scope.users().next()
287 >>> list(user.contacts())
288 [<scapi.Contact object at 0x12345>, ...]
289
290 """
291 - def __init__(self, connector, scope=None, parent=None):
292 """
293 Create the Scope. It can have a resource as scope, and possibly a parent-scope.
294
295 @param connector: The connector to use.
296 @type connector: ApiConnector
297 @type scope: scapi.RESTBase
298 @param scope: the resource to make this scope belong to
299 @type parent: scapi.Scope
300 @param parent: the parent scope of this scope
301 """
302
303 if scope is None:
304 scope = ()
305 else:
306 scope = scope,
307 if parent is not None:
308 scope = parent._scope + scope
309 self._scope = scope
310 self._connector = connector
311
313 return self._connector
314
315
317 """
318 This method will take an arbitrary url, and rewrite it
319 so that the current authenticator's oauth-headers are appended
320 as query-parameters.
321
322 This is used in streaming and downloading, because those content
323 isn't served from the SoundCloud servers themselves.
324
325 A usage example would look like this:
326
327 >>> sca = scapi.Scope(connector)
328 >>> track = sca.tracks(params={
329 "filter" : "downloadable",
330 }).next()
331
332
333 >>> download_url = track.download_url
334 >>> signed_url = track.oauth_sign_get_request(download_url)
335 >>> data = urllib2.urlopen(signed_url).read()
336
337 """
338 scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
339
340 req = urllib2.Request(url)
341
342 all_params = {}
343 if query:
344 all_params.update(cgi.parse_qs(query))
345
346 if not all_params:
347 all_params = None
348
349 self._connector.authenticator.augment_request(req, all_params, False)
350
351 auth_header = req.get_header("Authorization")
352 auth_header = auth_header[len("OAuth "):]
353
354 query_params = []
355 if query:
356 query_params.append(query)
357
358 for part in auth_header.split(","):
359 key, value = part.split("=")
360 assert key.startswith("oauth")
361 value = value[1:-1]
362 query_params.append("%s=%s" % (key, value))
363
364 query = "&".join(query_params)
365 url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
366 return url
367
368
369 - def _create_request(self, url, connector, parameters, queryparams, alternate_http_method=None, use_multipart=False):
370 """
371 This method returnes the urllib2.Request to perform the actual HTTP-request.
372
373 We return a subclass that overload the get_method-method to return a custom method like "PUT".
374 Additionally, the request is enhanced with the current authenticators authorization scheme
375 headers.
376
377 @param url: the destination url
378 @param connector: our connector-instance
379 @param parameters: the POST-parameters to use.
380 @type parameters: None|dict<str, basestring|list<basestring>>
381 @param queryparams: the queryparams to use
382 @type queryparams: None|dict<str, basestring|list<basestring>>
383 @param alternate_http_method: an alternate HTTP-method to use
384 @type alternate_http_method: str
385 @return: the fully equipped request
386 @rtype: urllib2.Request
387 """
388 class MyRequest(urllib2.Request):
389 def get_method(self):
390 if alternate_http_method is not None:
391 return alternate_http_method
392 return urllib2.Request.get_method(self)
393
394 def has_data(self):
395 return parameters is not None
396
397 def augment_request(self, params, use_multipart=False):
398 connector.authenticator.augment_request(self, params, use_multipart)
399
400 @classmethod
401 def recreate_request(cls, location):
402 return self._create_request(location, connector, None, None)
403
404 req = MyRequest(url)
405 all_params = {}
406 if parameters is not None:
407 all_params.update(parameters)
408 if queryparams is not None:
409 all_params.update(queryparams)
410 if not all_params:
411 all_params = None
412 req.augment_request(all_params, use_multipart)
413 req.add_header("Accept", "application/json")
414 return req
415
416
418 """
419 Small helpermethod to create the querystring from a dict.
420
421 @type queryparams: None|dict<str, basestring|list<basestring>>
422 @param queryparams: the queryparameters.
423 @return: either the empty string, or a "?" followed by the parameters joined by "&"
424 @rtype: str
425 """
426 if not queryparams:
427 return ""
428 h = []
429 for key, values in queryparams.iteritems():
430 if isinstance(values, (int, long, float)):
431 values = str(values)
432 if isinstance(values, basestring):
433 values = [values]
434 for v in values:
435 v = v.encode("utf-8")
436 h.append("%s=%s" % (key, escape(v)))
437 return "?" + "&".join(h)
438
439
440 - def _call(self, method, *args, **kwargs):
441 """
442 The workhorse. It's complicated, convoluted and beyond understanding of a mortal being.
443
444 You have been warned.
445 """
446
447 queryparams = {}
448 __offset__ = ApiConnector.LIST_LIMIT
449 if "__offset__" in kwargs:
450 offset = kwargs.pop("__offset__")
451 queryparams['offset'] = offset
452 __offset__ = offset + ApiConnector.LIST_LIMIT
453
454 if "params" in kwargs:
455 queryparams.update(kwargs.pop("params"))
456
457
458 _cl_method = method
459 _cl_args = tuple(args)
460 _cl_kwargs = {}
461 _cl_kwargs.update(kwargs)
462 _cl_kwargs["__offset__"] = __offset__
463 def continue_list_fetching():
464 return self._call(method, *_cl_args, **_cl_kwargs)
465 connector = self._get_connector()
466 def filelike(v):
467 if isinstance(v, file):
468 return True
469 if hasattr(v, "read"):
470 return True
471 return False
472 alternate_http_method = None
473 if "_alternate_http_method" in kwargs:
474 alternate_http_method = kwargs.pop("_alternate_http_method")
475 urlparams = kwargs if kwargs else None
476 use_multipart = False
477 if urlparams is not None:
478 fileargs = dict((key, value) for key, value in urlparams.iteritems() if filelike(value))
479 use_multipart = bool(fileargs)
480
481
482 if method[-1] != "/":
483 method = method + "/"
484 if args:
485 method = "%s%s" % (method, "/".join(str(a) for a in args))
486
487 scope = ''
488 if self._scope:
489 scopes = self._scope
490 if connector.collapse_scope:
491 scopes = scopes[-1:]
492 scope = "/".join([sc._scope() for sc in scopes]) + "/"
493 url = "http://%(host)s/%(base)s%(scope)s%(method)s%(queryparams)s" % dict(host=connector.host, method=method, base=connector._base, scope=scope, queryparams=self._create_query_string(queryparams))
494
495
496
497
498 redirect_handler = SCRedirectHandler()
499 handlers = [redirect_handler]
500 if USE_PROXY:
501 handlers.append(urllib2.ProxyHandler({'http' : PROXY}))
502 req = self._create_request(url, connector, urlparams, queryparams, alternate_http_method, use_multipart)
503
504 http_method = req.get_method()
505 if urlparams is not None:
506 logger.debug("Posting url: %s, method: %s", url, http_method)
507 else:
508 logger.debug("Fetching url: %s, method: %s", url, http_method)
509
510
511 if use_multipart:
512 handlers.extend([MultipartPostHandler])
513 else:
514 if urlparams is not None:
515 urlparams = urllib.urlencode(urlparams.items(), True)
516 opener = urllib2.build_opener(*handlers)
517 try:
518 handle = opener.open(req, urlparams)
519 except NoResultFromRequest:
520 return None
521 except urllib2.HTTPError, e:
522 if http_method == "GET" and e.code == 404:
523 return None
524 raise
525
526 info = handle.info()
527 ct = info['Content-Type']
528 content = handle.read()
529 logger.debug("Content-type:%s", ct)
530 logger.debug("Request Content:\n%s", content)
531 if redirect_handler.alternate_method is not None:
532 method = connector.normalize_method(redirect_handler.alternate_method)
533 logger.debug("Method changed through redirect to: <%s>", method)
534
535 try:
536 if "application/json" in ct:
537 content = content.strip()
538 if not content:
539 content = "{}"
540 try:
541 res = simplejson.loads(content)
542 except:
543 logger.error("Couldn't decode returned json")
544 logger.error(content)
545 raise
546 res = self._map(res, method, continue_list_fetching)
547 return res
548 elif len(content) <= 1:
549
550
551 pass
552 raise UnknownContentType("%s, returned:\n%s" % (ct, content))
553 finally:
554 handle.close()
555
556 - def _map(self, res, method, continue_list_fetching):
557 """
558 This method will take the JSON-result of a HTTP-call and return our domain-objects.
559
560 It's also deep magic, don't look.
561 """
562 pathparts = reversed(method.split("/"))
563 stack = []
564 for part in pathparts:
565 stack.append(part)
566 if part in RESTBase.REGISTRY:
567 cls = RESTBase.REGISTRY[part]
568
569 if isinstance(res, list):
570 def result_gen():
571 count = 0
572 for item in res:
573 yield cls(item, self, stack)
574 count += 1
575 if count == ApiConnector.LIST_LIMIT:
576 for item in continue_list_fetching():
577 yield item
578 return result_gen()
579 else:
580 return cls(res, self, stack)
581 logger.debug("don't know how to handle result")
582 logger.debug(res)
583 return res
584
586 """
587 Retrieve an API-method or a scoped domain-class.
588
589 If the former, result is a callable that supports the following invocations:
590
591 - calling (...), with possible arguments (positional/keyword), return the resulting resource or list of resources.
592 When calling, you can pass a keyword-argument B{params}. This must be a dict or L{MultiDict} and will be used to add additional query-get-parameters.
593
594 - invoking append(resource) on it will PUT the resource, making it part of the current resource. Makes
595 sense only if it's a collection of course.
596
597 - invoking remove(resource) on it will DELETE the resource from it's container. Also only usable on collections.
598
599 TODO: describe the latter
600 """
601 scope = self
602
603 class api_call(object):
604 def __call__(selfish, *args, **kwargs):
605 return self._call(_name, *args, **kwargs)
606
607 def new(self, **kwargs):
608 """
609 Will invoke the new method on the named resource _name, with
610 self as scope.
611 """
612 cls = RESTBase.REGISTRY[_name]
613 return cls.new(scope, **kwargs)
614
615 def append(selfish, resource):
616 """
617 If the current scope is
618 """
619 self._call(_name, str(resource.id), _alternate_http_method="PUT")
620
621 def remove(selfish, resource):
622 self._call(_name, str(resource.id), _alternate_http_method="DELETE")
623
624 if _name in RESTBase.ALL_DOMAIN_CLASSES:
625 cls = RESTBase.ALL_DOMAIN_CLASSES[_name]
626
627 class ScopeBinder(object):
628 def new(self, *args, **data):
629
630 d = MultiDict()
631 name = cls._singleton()
632
633 def unfold_value(key, value):
634 if isinstance(value, (basestring, file)):
635 d.add(key, value)
636 elif isinstance(value, dict):
637 for sub_key, sub_value in value.iteritems():
638 unfold_value("%s[%s]" % (key, sub_key), sub_value)
639 else:
640
641 for sub_value in value:
642 unfold_value(key + "[]", sub_value)
643
644
645 for key, value in data.iteritems():
646 unfold_value("%s[%s]" % (name, key), value)
647
648 return scope._call(cls.KIND, **d)
649
650 def create(self, **data):
651 return cls.create(scope, **data)
652
653 def get(self, id):
654 return cls.get(scope, id)
655
656
657 return ScopeBinder()
658 return api_call()
659
662
664 scopes = self._scope
665 base = ""
666 if len(scopes) > 1:
667 base = str(scopes[-2])
668 return base + "/" + str(scopes[-1])
669
670
671
672
673
674
675
676
677
678
679
680 -class RESTBase(object):
681 """
682 The baseclass for all our domain-objects/resources.
683
684
685 """
686 REGISTRY = {}
687
688 ALL_DOMAIN_CLASSES = {}
689
690 ALIASES = []
691
692 KIND = None
693
694 - def __init__(self, data, scope, path_stack=None):
695 self.__data = data
696 self.__scope = scope
697
698 logger.debug("path_stack: %r", path_stack)
699 if path_stack:
700 try:
701 id = int(path_stack[0])
702 self.__data['id'] = id
703 except ValueError:
704 pass
705
707 if name in self.__data:
708 obj = self.__data[name]
709 if name in RESTBase.REGISTRY:
710 if isinstance(obj, dict):
711 obj = RESTBase.REGISTRY[name](obj, self.__scope)
712 elif isinstance(obj, list):
713 obj = [RESTBase.REGISTRY[name](o, self.__scope) for o in obj]
714 else:
715 logger.warning("Found %s in our registry, but don't know what to do with"\
716 "the object.")
717 return obj
718 scope = Scope(self.__scope._get_connector(), scope=self, parent=self.__scope)
719 return getattr(scope, name)
720
722 """
723 This method is used to set a property, a resource or a list of resources as property of the resource the
724 method is invoked on.
725
726 For example, to set a comment on a track, do
727
728 >>> sca = scapi.Scope(connector)
729 >>> track = scapi.Track.new(title='bar', sharing="private")
730 >>> comment = scapi.Comment.create(body="This is the body of my comment", timestamp=10)
731 >>> track.comments = comment
732
733 To set a list of users as permissions, do
734
735 >>> sca = scapi.Scope(connector)
736 >>> me = sca.me()
737 >>> track = scapi.Track.new(title='bar', sharing="private")
738 >>> users = sca.users()
739 >>> users_to_set = [user for user in users[:10] if user != me]
740 >>> track.permissions = users_to_set
741
742 And finally, to simply change the title of a track, do
743
744 >>> sca = scapi.Scope(connector)
745 >>> track = sca.Track.get(track_id)
746 >>> track.title = "new_title"
747
748 @param name: the property name
749 @type name: str
750 @param value: the property, resource or resources to set
751 @type value: RESTBase | list<RESTBase> | basestring | long | int | float
752 @return: None
753 """
754
755
756 if "_RESTBase__" in name:
757 self.__dict__[name] = value
758 else:
759 if isinstance(value, list) and len(value):
760
761
762
763 parameter_name = "%s[%s_id][]" % (name, value[0]._singleton())
764 values = [o.id for o in value]
765 kwargs = {"_alternate_http_method" : "PUT",
766 parameter_name : values}
767 self.__scope._call(self.KIND, self.id, name, **kwargs)
768 elif isinstance(value, RESTBase):
769
770 self.__scope._call(self.KIND, self.id, name, **value._as_arguments())
771 else:
772
773 parameter_name = "%s[%s]" % (self._singleton(), name)
774 kwargs = {"_alternate_http_method" : "PUT",
775 parameter_name : self._convert_value(value)}
776 self.__scope._call(self.KIND, self.id, **kwargs)
777
779 """
780 Converts a resource to a argument-string the way Rails expects it.
781 """
782 res = {}
783 for key, value in self.__data.items():
784 value = self._convert_value(value)
785 res["%s[%s]" % (self._singleton(), key)] = value
786 return res
787
789 if isinstance(value, unicode):
790 value = value.encode("utf-8")
791 elif isinstance(value, file):
792 pass
793 else:
794 value = str(value)
795 return value
796
797 @classmethod
798 - def create(cls, scope, **data):
799 """
800 This is a convenience-method for creating an object that will be passed
801 as parameter - e.g. a comment. A usage would look like this:
802
803 >>> sca = scapi.Scope(connector)
804 >>> track = sca.Track.new(title='bar', sharing="private")
805 >>> comment = sca.Comment.create(body="This is the body of my comment", timestamp=10)
806 >>> track.comments = comment
807
808 """
809 return cls(data, scope)
810
811 @classmethod
812 - def new(cls, scope, **data):
813 """
814 Create a new resource inside a given Scope. The actual values are in data.
815
816 So for creating new resources, you have two options:
817
818 - create an instance directly using the class:
819
820 >>> scope = scapi.Scope(connector)
821 >>> scope.User.new(...)
822 <scapi.User object at 0x1234>
823
824 - create a instance in a certain scope:
825
826 >>> scope = scapi.Scope(connector)
827 >>> user = scapi.User("1")
828 >>> track = user.tracks.new()
829 <scapi.Track object at 0x1234>
830
831 @param scope: if not empty, a one-element tuple containing the Scope
832 @type scope: tuple<Scope>[1]
833 @param data: the data
834 @type data: dict
835 @return: new instance of the resource
836 """
837 return getattr(scope, cls.__name__).new(**data)
838
839 @classmethod
840 - def get(cls, scope, id):
841 """
842 Fetch a resource by id.
843
844 Simply pass a known id as argument. For example
845
846 >>> sca = scapi.Scope(connector)
847 >>> track = sca.Track.get(id)
848
849 """
850 return getattr(scope, cls.KIND)(id)
851
852
854 """
855 Return the scope this resource lives in, which is the KIND and id
856
857 @return: "<KIND>/<id>"
858 """
859 return "%s/%s" % (self.KIND, str(self.id))
860
861 @classmethod
863 """
864 This method will take a resource name like "users" and
865 return the single-case, in the example "user".
866
867 Currently, it's not very sophisticated, only strips a trailing s.
868 """
869 name = cls.KIND
870 if name[-1] == 's':
871 return name[:-1]
872 raise ValueError("Can't make %s to a singleton" % name)
873
875 res = []
876 res.append("\n\n******\n%s:" % self.__class__.__name__)
877 res.append("")
878 for key, v in self.__data.iteritems():
879 key = str(key)
880 if isinstance(v, unicode):
881 v = v.encode('utf-8')
882 else:
883 v = str(v)
884 res.append("%s=%s" % (key, v))
885 return "\n".join(res)
886
888 return hash("%s%i" % (self.KIND, self.id))
889
891 """
892 Test for equality.
893
894 Resources are considered equal if the have the same kind and id.
895 """
896 if not isinstance(other, RESTBase):
897 return False
898 res = self.KIND == other.KIND and self.id == other.id
899 return res
900
902 return not self == other
903
904 -class User(RESTBase):
905 """
906 A user domain object/resource.
907 """
908 KIND = 'users'
909 ALIASES = ['me', 'permissions', 'contacts', 'user']
910
912 """
913 A track domain object/resource.
914 """
915 KIND = 'tracks'
916 ALIASES = ['favorites']
917
923
925 """
926 A event domain object/resource.
927 """
928 KIND = 'events'
929
931 """
932 A playlist/set domain object/resource
933 """
934 KIND = 'playlists'
935
937 """
938 A group domain object/resource
939 """
940 KIND = 'groups'
941
956 register_classes()
957