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