Some fixes to make diaspy more Python 2.x-friendly
[diaspy.git] / diaspy / streams.py
CommitLineData
505fc964
MM
1"""Docstrings for this module are taken from:
2https://gist.github.com/MrZYX/01c93096c30dc44caf71
3
4Documentation for D* JSON API taken from:
5http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
6"""
1232dac5 7
f605e88d 8
4b1645dc
MM
9import json
10import time
11from diaspy.models import Post, Aspect
12from diaspy import errors
13
14
7a818fdb
MM
15class Generic():
16 """Object representing generic stream.
1232dac5 17 """
27a28aaf 18 _location = 'stream.json'
27a28aaf 19
b2905ea6 20 def __init__(self, connection, location='', fetch=True):
1232dac5
MM
21 """
22 :param connection: Connection() object
505fc964 23 :type connection: diaspy.connection.Connection
27a28aaf 24 :param location: location of json (optional)
505fc964 25 :type location: str
b2905ea6
MM
26 :param fetch: will call .fill() if true
27 :type fetch: bool
1232dac5
MM
28 """
29 self._connection = connection
505fc964 30 if location: self._location = location
63f1d9f1
MM
31 self._stream = []
32 # since epoch
33 self.max_time = int(time.mktime(time.gmtime()))
b2905ea6 34 if fetch: self.fill()
1232dac5
MM
35
36 def __contains__(self, post):
37 """Returns True if stream contains given post.
38 """
1232dac5
MM
39 return post in self._stream
40
41 def __iter__(self):
42 """Provides iterable interface for stream.
43 """
44 return iter(self._stream)
45
46 def __getitem__(self, n):
47 """Returns n-th item in Stream.
48 """
49 return self._stream[n]
50
51 def __len__(self):
52 """Returns length of the Stream.
53 """
54 return len(self._stream)
55
ed366d44 56 def _obtain(self, max_time=0):
1232dac5
MM
57 """Obtains stream from pod.
58 """
ed366d44 59 params = {}
dde4ddb7
MM
60 if max_time:
61 params['max_time'] = max_time
fe783229 62 params['_'] = int(time.time() * 1000)
39af9756 63 request = self._connection.get(self._location, params=params)
1232dac5 64 if request.status_code != 200:
4b1645dc 65 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
6c692631 66 return [Post(self._connection, guid=post['guid']) for post in request.json()]
1232dac5 67
ed366d44
MM
68 def _expand(self, new_stream):
69 """Appends older posts to stream.
70 """
dde4ddb7 71 ids = [post.id for post in self._stream]
ed366d44
MM
72 stream = self._stream
73 for post in new_stream:
dde4ddb7 74 if post.id not in ids:
ed366d44 75 stream.append(post)
dde4ddb7 76 ids.append(post.id)
ed366d44
MM
77 self._stream = stream
78
79 def _update(self, new_stream):
80 """Updates stream with new posts.
81 """
78cc478a 82 ids = [post.id for post in self._stream]
ed366d44
MM
83
84 stream = self._stream
85 for i in range(len(new_stream)):
78cc478a 86 if new_stream[-i].id not in ids:
ed366d44 87 stream = [new_stream[-i]] + stream
9aa1c960 88 ids.append(new_stream[-i].id)
ed366d44
MM
89 self._stream = stream
90
1232dac5 91 def clear(self):
2cf8467c 92 """Set stream to empty.
1232dac5
MM
93 """
94 self._stream = []
95
505fc964
MM
96 def purge(self):
97 """Removes all unexistent posts from stream.
98 """
99 stream = []
100 for post in self._stream:
101 deleted = False
102 try:
39af9756 103 # error will tell us that the post has been deleted
78cc478a 104 post.update()
505fc964
MM
105 except Exception:
106 deleted = True
107 finally:
108 if not deleted: stream.append(post)
109 self._stream = stream
110
1232dac5 111 def update(self):
1aae28a3 112 """Updates stream with new posts.
1232dac5 113 """
3cf4514e 114 self._update(self._obtain())
1232dac5
MM
115
116 def fill(self):
117 """Fills the stream with posts.
1aae28a3
MM
118
119 **Notice:** this will create entirely new list of posts.
120 If you want to preseve posts already present in stream use update().
1232dac5
MM
121 """
122 self._stream = self._obtain()
123
fe783229 124 def more(self, max_time=0, backtime=84600):
1aae28a3 125 """Tries to download more (older posts) posts from Stream.
ed366d44 126
fe783229
MM
127 :param backtime: how many seconds substract each time (defaults to one day)
128 :type backtime: int
ed366d44
MM
129 :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
130 :type max_time: int
33b34938 131 """
fe783229 132 if not max_time: max_time = self.max_time - backtime
178faa46 133 self.max_time = max_time
ed366d44
MM
134 new_stream = self._obtain(max_time=max_time)
135 self._expand(new_stream)
33b34938 136
fe783229 137 def full(self, backtime=84600, retry=42, callback=None):
f61c14c1 138 """Fetches full stream - containing all posts.
fe783229
MM
139 WARNING: this is a **VERY** long running function.
140 Use callback parameter to access information about the stream during its
141 run.
142
143 Default backtime is one day. But sometimes user might not have any activity for longer
1aae28a3 144 period (in the beginning of my D* activity I was posting once a month or so).
fe783229
MM
145 The role of retry is to hadle such situations by trying to go further back in time.
146 If a post is found the counter is restored.
147
1aae28a3
MM
148 Default retry is 42. If you don't know why go to the nearest library (or to the nearest
149 Piratebay mirror) and grab a copy of "A Hitchhiker's Guide to the Galaxy" and read the
150 book to find out. This will also increase your level of geekiness and you'll have a
151 great time reading the book.
152
fe783229
MM
153 :param backtime: how many seconds to substract each time
154 :type backtime: int
155 :param retry: how many times the functin should look deeper than your last post
156 :type retry: int
157 :param callback: callable taking diaspy.streams.Generic as an argument
f61c14c1
MM
158 :returns: integer, lenght of the stream
159 """
160 oldstream = self.copy()
161 self.more()
fe783229 162 while len(oldstream) < len(self):
f61c14c1 163 oldstream = self.copy()
fe783229
MM
164 if callback is not None: callback(self)
165 self.more(backtime=backtime)
166 if len(oldstream) < len(self): continue
167 # but if no posts were found start retrying...
168 print('retrying... {0}'.format(retry))
169 n = retry
170 while n > 0:
171 print('\t', n, self.max_time)
172 # try to get even more posts...
173 self.more(backtime=backtime)
174 print('\t', len(oldstream), len(self))
175 # check if it was a success...
176 if len(oldstream) < len(self):
177 # and if so restore normal order of execution by
178 # going one loop higher
179 break
180 oldstream = self.copy()
b2905ea6
MM
181 # if it was not a success substract one backtime, keep calm and
182 # try going further back in time...
fe783229 183 n -= 1
b2905ea6
MM
184 # check the comment below
185 # no commented code should be present in good software
fe783229 186 #if len(oldstream) == len(self): break
f61c14c1
MM
187 return len(self)
188
dde4ddb7
MM
189 def copy(self):
190 """Returns copy (list of posts) of current stream.
191 """
192 return [p for p in self._stream]
193
6c692631 194 def json(self, comments=False, **kwargs):
f61c14c1
MM
195 """Returns JSON encoded string containing stream's data.
196
197 :param comments: to include comments or not to include 'em, that is the question this param holds answer to
198 :type comments: bool
199 """
200 stream = [post for post in self._stream]
201 if comments:
202 for i, post in enumerate(stream):
203 post._fetchcomments()
204 comments = [c.data for c in post.comments]
205 post['interactions']['comments'] = comments
206 stream[i] = post
6c692631
MM
207 stream = [post._data for post in stream]
208 return json.dumps(stream, **kwargs)
f61c14c1 209
1232dac5 210
beaa09fb
MM
211class Outer(Generic):
212 """Object used by diaspy.models.User to represent
213 stream of other user.
214 """
dde4ddb7
MM
215 def _obtain(self, max_time=0):
216 """Obtains stream from pod.
beaa09fb 217 """
dde4ddb7
MM
218 params = {}
219 if max_time: params['max_time'] = max_time
220 request = self._connection.get(self._location, params=params)
beaa09fb 221 if request.status_code != 200:
dde4ddb7 222 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
78cc478a 223 return [Post(self._connection, post['id']) for post in request.json()]
beaa09fb
MM
224
225
1232dac5 226class Stream(Generic):
f605e88d
MM
227 """The main stream containing the combined posts of the
228 followed users and tags and the community spotlights posts
505fc964 229 if the user enabled those.
1232dac5 230 """
27a28aaf 231 location = 'stream.json'
505fc964 232
952a429e 233 def post(self, text='', aspect_ids='public', photos=None, photo='', provider_display_name=''):
a98c6792
MM
234 """This function sends a post to an aspect.
235 If both `photo` and `photos` are specified `photos` takes precedence.
1232dac5
MM
236
237 :param text: Text to post.
238 :type text: str
239 :param aspect_ids: Aspect ids to send post to.
240 :type aspect_ids: str
a98c6792
MM
241 :param photo: filename of photo to post
242 :type photo: str
243 :param photos: id of photo to post (obtained from _photoupload())
244 :type photos: int
952a429e
SB
245 :param provider_display_name: name of provider displayed under the post
246 :type provider_display_name: str
1232dac5
MM
247
248 :returns: diaspy.models.Post -- the Post which has been created
249 """
250 data = {}
251 data['aspect_ids'] = aspect_ids
952a429e 252 data['status_message'] = {'text': text, 'provider_display_name': provider_display_name}
a98c6792 253 if photo: data['photos'] = self._photoupload(photo)
1232dac5 254 if photos: data['photos'] = photos
a98c6792 255
1232dac5
MM
256 request = self._connection.post('status_messages',
257 data=json.dumps(data),
258 headers={'content-type': 'application/json',
259 'accept': 'application/json',
78cc478a 260 'x-csrf-token': repr(self._connection)})
1232dac5 261 if request.status_code != 201:
a98c6792 262 raise Exception('{0}: Post could not be posted.'.format(request.status_code))
78cc478a 263 post = Post(self._connection, request.json()['id'])
1232dac5
MM
264 return post
265
39af9756 266 def _photoupload(self, filename, aspects=[]):
66c3bb76 267 """Uploads picture to the pod.
1232dac5 268
66c3bb76 269 :param filename: path to picture file
1232dac5 270 :type filename: str
39af9756
MM
271 :param aspect_ids: list of ids of aspects to which you want to upload this photo
272 :type aspect_ids: list of integers
66c3bb76
MM
273
274 :returns: id of the photo being uploaded
1232dac5 275 """
38fabb63
MM
276 data = open(filename, 'rb')
277 image = data.read()
278 data.close()
279
1232dac5
MM
280 params = {}
281 params['photo[pending]'] = 'true'
282 params['set_profile_image'] = ''
283 params['qqfile'] = filename
39af9756 284 if not aspects: aspects = self._connection.getUserData()['aspects']
1232dac5 285 for i, aspect in enumerate(aspects):
38fabb63 286 params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
1232dac5
MM
287
288 headers = {'content-type': 'application/octet-stream',
78cc478a 289 'x-csrf-token': repr(self._connection),
1232dac5 290 'x-file-name': filename}
38fabb63
MM
291
292 request = self._connection.post('photos', data=image, params=params, headers=headers)
293 if request.status_code != 200:
2d4f6eeb 294 raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code))
66c3bb76
MM
295 return request.json()['data']['photo']['id']
296
1232dac5 297
278febce 298class Activity(Stream):
1232dac5
MM
299 """Stream representing user's activity.
300 """
27a28aaf 301 _location = 'activity.json'
505fc964
MM
302
303 def _delid(self, id):
304 """Deletes post with given id.
305 """
306 post = None
307 for p in self._stream:
308 if p['id'] == id:
309 post = p
310 break
311 if post is not None: post.delete()
312
313 def delete(self, post):
314 """Deletes post from users activity.
315 `post` can be either post id or Post()
316 object which will be identified and deleted.
39af9756 317 After deleting post the stream will be purged.
505fc964
MM
318
319 :param post: post identifier
320 :type post: str, diaspy.models.Post
321 """
322 if type(post) == str: self._delid(post)
323 elif type(post) == Post: post.delete()
63f1d9f1 324 else: raise TypeError('this method accepts str or Post types: {0} given')
39af9756 325 self.purge()
505fc964
MM
326
327
328class Aspects(Generic):
f605e88d
MM
329 """This stream contains the posts filtered by
330 the specified aspect IDs. You can choose the aspect IDs with
331 the parameter `aspect_ids` which value should be
332 a comma seperated list of aspect IDs.
333 If the parameter is ommitted all aspects are assumed.
505fc964
MM
334 An example call would be `aspects.json?aspect_ids=23,5,42`
335 """
27a28aaf 336 _location = 'aspects.json'
27a28aaf 337
278febce 338 def getAspectID(self, aspect_name):
63cc182d
MM
339 """Returns id of an aspect of given name.
340 Returns -1 if aspect is not found.
341
278febce
MM
342 :param aspect_name: aspect name (must be spelled exactly as when created)
343 :type aspect_name: str
63cc182d
MM
344 :returns: int
345 """
346 id = -1
39af9756 347 aspects = self._connection.getUserData()['aspects']
278febce
MM
348 for aspect in aspects:
349 if aspect['name'] == aspect_name: id = aspect['id']
63cc182d
MM
350 return id
351
39af9756
MM
352 def filter(self, ids):
353 """Filters posts by given aspect ids.
354
355 :parameter ids: list of apsect ids
356 :type ids: list of integers
357 """
358 self._location = 'aspects.json' + '?{0}'.format(','.join(ids))
27a28aaf
MM
359 self.fill()
360
505fc964
MM
361 def add(self, aspect_name, visible=0):
362 """This function adds a new aspect.
278febce 363 Status code 422 is accepted because it is returned by D* when
27a28aaf
MM
364 you try to add aspect already present on your aspect list.
365
b2905ea6
MM
366 :param aspect_name: name of aspect to create
367 :param visible: whether the contacts in this aspect are visible to each other or not
368
d589deff 369 :returns: Aspect() object of just created aspect
505fc964 370 """
b2905ea6 371 data = {'authenticity_token': repr(self._connection),
505fc964
MM
372 'aspect[name]': aspect_name,
373 'aspect[contacts_visible]': visible}
374
27a28aaf
MM
375 request = self._connection.post('aspects', data=data)
376 if request.status_code not in [200, 422]:
377 raise Exception('wrong status code: {0}'.format(request.status_code))
378
278febce 379 id = self.getAspectID(aspect_name)
d589deff 380 return Aspect(self._connection, id)
505fc964 381
73a9e0d3 382 def remove(self, id=-1, name=''):
fbb19900 383 """This method removes an aspect.
278febce
MM
384 You can give it either id or name of the aspect.
385 When both are specified, id takes precedence over name.
386
387 Status code 500 is accepted because although the D* will
27a28aaf
MM
388 go nuts it will remove the aspect anyway.
389
390 :param aspect_id: id fo aspect to remove
391 :type aspect_id: int
1467ec15
MM
392 :param name: name of aspect to remove
393 :type name: str
fbb19900 394 """
73a9e0d3 395 if id == -1 and name: id = self.getAspectID(name)
27f09973 396 data = {'_method': 'delete',
b2905ea6 397 'authenticity_token': repr(self._connection)}
73a9e0d3 398 request = self._connection.post('aspects/{0}'.format(id), data=data)
dd0a4d9f
MM
399 if request.status_code not in [200, 302, 500]:
400 raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code))
fbb19900 401
505fc964
MM
402
403class Commented(Generic):
f605e88d 404 """This stream contains all posts
505fc964
MM
405 the user has made a comment on.
406 """
27a28aaf 407 _location = 'commented.json'
505fc964
MM
408
409
410class Liked(Generic):
411 """This stream contains all posts the user liked.
412 """
27a28aaf 413 _location = 'liked.json'
505fc964
MM
414
415
416class Mentions(Generic):
f605e88d 417 """This stream contains all posts
505fc964
MM
418 the user is mentioned in.
419 """
27a28aaf 420 _location = 'mentions.json'
505fc964
MM
421
422
423class FollowedTags(Generic):
f605e88d 424 """This stream contains all posts
6c416e80 425 containing tags the user is following.
505fc964 426 """
27a28aaf 427 _location = 'followed_tags.json'
505fc964 428
39af9756
MM
429 def get(self):
430 """Returns list of followed tags.
431 """
432 return []
433
a7c098e3 434 def remove(self, tag_id):
fbb19900
MM
435 """Stop following a tag.
436
a7c098e3
MM
437 :param tag_id: tag id
438 :type tag_id: int
fbb19900 439 """
f605e88d 440 data = {'authenticity_token': self._connection.get_token()}
a7c098e3
MM
441 request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data)
442 if request.status_code != 404:
443 raise Exception('wrong status code: {0}'.format(request.status_code))
fbb19900
MM
444
445 def add(self, tag_name):
505fc964 446 """Follow new tag.
f605e88d 447 Error code 403 is accepted because pods respod with it when request
6c416e80 448 is sent to follow a tag that a user already follows.
505fc964
MM
449
450 :param tag_name: tag name
451 :type tag_name: str
6c416e80 452 :returns: int (response code)
505fc964 453 """
f605e88d 454 data = {'name': tag_name,
b2905ea6 455 'authenticity_token': repr(self._connection),
f605e88d
MM
456 }
457 headers = {'content-type': 'application/json',
b2905ea6 458 'x-csrf-token': repr(self._connection),
f605e88d
MM
459 'accept': 'application/json'
460 }
f0fa9fec
MK
461
462 request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers)
463
6c416e80 464 if request.status_code not in [201, 403]:
505fc964 465 raise Exception('wrong error code: {0}'.format(request.status_code))
6c416e80 466 return request.status_code
2ec93347 467
7a818fdb
MM
468
469class Tag(Generic):
470 """This stream contains all posts containing a tag.
471 """
39af9756 472 def __init__(self, connection, tag, fetch=True):
7a818fdb
MM
473 """
474 :param connection: Connection() object
475 :type connection: diaspy.connection.Connection
476 :param tag: tag name
477 :type tag: str
478 """
479 self._connection = connection
6cd1bae0 480 self._location = 'tags/{0}.json'.format(tag)
39af9756 481 if fetch: self.fill()