1 """Docstrings for this module are taken from:
2 https://gist.github.com/MrZYX/01c93096c30dc44caf71
4 Documentation for D* JSON API taken from:
5 http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
11 from diaspy
.models
import Post
, Aspect
12 from diaspy
import errors
15 Remember created_at is in UTC
17 We need this to get a UTC timestamp from the latest loaded post in the
18 stream, so we can use it for the more() function.
20 import dateutil
.parser
21 def parse_utc_timestamp(date_str
):
22 return round(dateutil
.parser
.parse(date_str
).timestamp())
25 """Object representing generic stream.
27 _location
= 'stream.json'
29 def __init__(self
, connection
, location
='', fetch
=True):
31 :param connection: Connection() object
32 :type connection: diaspy.connection.Connection
33 :param location: location of json (optional)
35 :param fetch: will call .fill() if true
38 self
._connection
= connection
39 if location
: self
._location
= location
43 self
.max_time
= int(time
.mktime(time
.gmtime()))
46 def __contains__(self
, post
):
47 """Returns True if stream contains given post.
49 return post
in self
._stream
52 """Provides iterable interface for stream.
54 return iter(self
._stream
)
56 def __getitem__(self
, n
):
57 """Returns n-th item in Stream.
59 return self
._stream
[n
]
62 """Returns length of the Stream.
64 return len(self
._stream
)
66 def _obtain(self
, max_time
=0, suppress
=True):
67 """Obtains stream from pod.
69 suppress:bool - suppress post-fetching errors (e.g. 404)
73 if self
.latest
== None:
74 self
.latest
= int(time
.mktime(time
.gmtime()) * 1000)
75 self
.latest
-= max_time
76 else: self
.latest
+= 1
77 params
['max_time'] = max_time
78 params
['_'] = self
.latest
79 request
= self
._connection
.get(self
._location
, params
=params
)
80 if request
.status_code
!= 200:
81 raise errors
.StreamError('wrong status code: {0}'.format(request
.status_code
))
83 latest_time
= None # Used to get the created_at from the latest posts we received.
84 for post
in request
.json():
87 if post
['interactions']['comments_count'] > 3: comments
= True
88 posts
.append(Post(self
._connection
, id=post
['id'], guid
=post
['guid'], fetch
=False, comments
=comments
, post_data
=post
))
89 if post
['created_at']: latest_time
= post
['created_at']
90 except errors
.PostError
:
94 self
.max_time
= parse_utc_timestamp( latest_time
)
97 def _expand(self
, new_stream
):
98 """Appends older posts to stream.
100 guids
= [post
.guid
for post
in self
._stream
]
101 stream
= self
._stream
102 for post
in new_stream
:
103 if post
.guid
not in guids
:
105 guids
.append(post
.guid
)
106 self
._stream
= stream
108 def _update(self
, new_stream
):
109 """Updates stream with new posts.
111 guids
= [post
.guid
for post
in self
._stream
]
113 stream
= self
._stream
114 for i
in range(len(new_stream
)):
115 if new_stream
[-i
].guid
not in guids
:
116 stream
= [new_stream
[-i
]] + stream
117 guids
.append(new_stream
[-i
].guid
)
118 self
._stream
= stream
121 """Set stream to empty.
126 """Removes all unexistent posts from stream.
129 for post
in self
._stream
:
132 # error will tell us that the post has been deleted
137 if not deleted
: stream
.append(post
)
138 self
._stream
= stream
141 """Updates stream with new posts.
143 self
._update
(self
._obtain
())
146 """Fills the stream with posts.
148 **Notice:** this will create entirely new list of posts.
149 If you want to preseve posts already present in stream use update().
151 self
._stream
= self
._obtain
()
153 def more(self
, max_time
=0):
154 """Tries to download more (older posts) posts from Stream.
156 :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
160 if not max_time
: max_time
= self
.max_time
161 self
.max_time
= max_time
162 new_stream
= self
._obtain
(max_time
=max_time
)
163 self
._expand
(new_stream
)
165 def full(self
, backtime
=86400, retry
=42, callback
=None):
166 """Fetches full stream - containing all posts.
167 WARNING: this is a **VERY** long running function.
168 Use callback parameter to access information about the stream during its
171 Default backtime is one day. But sometimes user might not have any activity for longer
172 period (in the beginning of my D* activity I was posting once a month or so).
173 The role of retry is to hadle such situations by trying to go further back in time.
174 If a post is found the counter is restored.
176 Default retry is 42. If you don't know why go to the nearest library (or to the nearest
177 Piratebay mirror) and grab a copy of "A Hitchhiker's Guide to the Galaxy" and read the
178 book to find out. This will also increase your level of geekiness and you'll have a
179 great time reading the book.
181 :param backtime: how many seconds to substract each time
183 :param retry: how many times the functin should look deeper than your last post
185 :param callback: callable taking diaspy.streams.Generic as an argument
186 :returns: integer, lenght of the stream
188 oldstream
= self
.copy()
190 while len(oldstream
) < len(self
):
191 oldstream
= self
.copy()
192 if callback
is not None: callback(self
)
193 self
.more(backtime
=backtime
)
194 if len(oldstream
) < len(self
): continue
195 # but if no posts were found start retrying...
196 print('[diaspy] retrying... {0}'.format(retry
))
199 # try to get even more posts...
200 self
.more(backtime
=backtime
)
201 # check if it was a success...
202 if len(oldstream
) < len(self
):
203 # and if so restore normal order of execution by
204 # going one loop higher
206 oldstream
= self
.copy()
207 # if it was not a success substract one backtime, keep calm and
208 # try going further back in time...
210 # check the comment below
211 # no commented code should be present in good software
212 #if len(oldstream) == len(self): break
216 """Returns copy (list of posts) of current stream.
218 return [p
for p
in self
._stream
]
220 def json(self
, comments
=False, **kwargs
):
221 """Returns JSON encoded string containing stream's data.
223 :param comments: to include comments or not to include 'em, that is the question this param holds answer to
226 stream
= [post
for post
in self
._stream
]
228 for i
, post
in enumerate(stream
):
229 post
._fetchcomments
()
230 comments
= [c
.data
for c
in post
.comments
]
231 post
['interactions']['comments'] = comments
233 stream
= [post
._data
for post
in stream
]
234 return json
.dumps(stream
, **kwargs
)
237 class Outer(Generic
):
238 """Object used by diaspy.models.User to represent
239 stream of other user.
241 def __init__(self
, connection
, guid
, fetch
=True):
242 location
= 'people/{}/stream.json'.format(guid
)
243 super().__init
__(connection
, location
, fetch
)
245 class Stream(Generic
):
246 """The main stream containing the combined posts of the
247 followed users and tags and the community spotlights posts
248 if the user enabled those.
250 location
= 'stream.json'
252 def post(self
, text
='', aspect_ids
='public', photos
=None, photo
='', poll_question
=None, poll_answers
=None, location_coords
=None, provider_display_name
=''):
253 """This function sends a post to an aspect.
254 If both `photo` and `photos` are specified `photos` takes precedence.
256 :param text: Text to post.
259 :param aspect_ids: Aspect ids to send post to.
260 :type aspect_ids: str
262 :param photo: filename of photo to post
265 :param photos: id of photo to post (obtained from _photoupload())
268 :param provider_display_name: name of provider displayed under the post
269 :type provider_display_name: str
271 :param poll_question: Question string
272 :type poll_question: str
274 :param poll_answers: Anwsers to the poll
275 :type poll_answers: list with strings
277 :param location_coords: TODO
278 :type location_coords: TODO
280 :returns: diaspy.models.Post -- the Post which has been created
283 data
['aspect_ids'] = aspect_ids
284 data
['status_message'] = {'text': text
, 'provider_display_name': provider_display_name
}
285 if photo
: data
['photos'] = self
._photoupload
(photo
)
286 if photos
: data
['photos'] = photos
287 if poll_question
and poll_answers
:
288 data
['poll_question'] = poll_question
289 data
['poll_answers'] = poll_answers
290 if location_coords
: data
['location_coords'] = location_coords
292 request
= self
._connection
.post('status_messages',
293 data
=json
.dumps(data
),
294 headers
={'content-type': 'application/json',
295 'accept': 'application/json',
296 'x-csrf-token': repr(self
._connection
)})
297 if request
.status_code
!= 201:
298 raise Exception('{0}: Post could not be posted.'.format(request
.status_code
))
299 post_json
= request
.json()
300 post
= Post(self
._connection
, id=post_json
['id'], guid
=post_json
['guid'], post_data
=post_json
)
303 def _photoupload(self
, filename
, aspects
=[]):
304 """Uploads picture to the pod.
306 :param filename: path to picture file
308 :param aspect_ids: list of ids of aspects to which you want to upload this photo
309 :type aspect_ids: list of integers
311 :returns: id of the photo being uploaded
313 data
= open(filename
, 'rb')
318 params
['photo[pending]'] = 'true'
319 params
['set_profile_image'] = ''
320 params
['qqfile'] = filename
321 if not aspects
: aspects
= self
._connection
.getUserData()['aspects']
322 for i
, aspect
in enumerate(aspects
):
323 params
['photo[aspect_ids][{0}]'.format(i
)] = aspect
['id']
325 headers
= {'content-type': 'application/octet-stream',
326 'x-csrf-token': repr(self
._connection
),
327 'x-file-name': filename
}
329 request
= self
._connection
.post('photos', data
=image
, params
=params
, headers
=headers
)
330 if request
.status_code
!= 200:
331 raise errors
.StreamError('photo cannot be uploaded: {0}'.format(request
.status_code
))
332 return request
.json()['data']['photo']['id']
335 class Activity(Stream
):
336 """Stream representing user's activity.
338 _location
= 'activity.json'
340 def _delid(self
, id):
341 """Deletes post with given id.
344 for p
in self
._stream
:
348 if post
is not None: post
.delete()
350 def delete(self
, post
):
351 """Deletes post from users activity.
352 `post` can be either post id or Post()
353 object which will be identified and deleted.
354 After deleting post the stream will be purged.
356 :param post: post identifier
357 :type post: str, diaspy.models.Post
359 if type(post
) == str: self
._delid
(post
)
360 elif type(post
) == Post
: post
.delete()
361 else: raise TypeError('this method accepts str or Post types: {0} given')
365 class Aspects(Generic
):
366 """This stream contains the posts filtered by
367 the specified aspect IDs. You can choose the aspect IDs with
368 the parameter `aspect_ids` which value should be
369 a comma seperated list of aspect IDs.
370 If the parameter is ommitted all aspects are assumed.
371 An example call would be `aspects.json?aspect_ids=23,5,42`
373 _location
= 'aspects.json'
375 def getAspectID(self
, aspect_name
):
376 """Returns id of an aspect of given name.
377 Returns -1 if aspect is not found.
379 :param aspect_name: aspect name (must be spelled exactly as when created)
380 :type aspect_name: str
384 aspects
= self
._connection
.getUserData()['aspects']
385 for aspect
in aspects
:
386 if aspect
['name'] == aspect_name
: id = aspect
['id']
389 def filter(self
, ids
):
390 """Filters posts by given aspect ids.
392 :parameter ids: list of apsect ids
393 :type ids: list of integers
395 self
._location
= 'aspects.json?a_ids[]=' + '{}'.format('&a_ids[]='.join(ids
))
396 self
.fill() # this will create entirely new list of posts.
398 def add(self
, aspect_name
, visible
=0):
399 """This function adds a new aspect.
400 Status code 422 is accepted because it is returned by D* when
401 you try to add aspect already present on your aspect list.
403 :param aspect_name: name of aspect to create
404 :param visible: whether the contacts in this aspect are visible to each other or not
406 :returns: Aspect() object of just created aspect
408 data
= {'authenticity_token': repr(self
._connection
),
409 'aspect[name]': aspect_name
,
410 'aspect[contacts_visible]': visible
}
412 request
= self
._connection
.post('aspects', data
=data
)
413 if request
.status_code
not in [200, 422]:
414 raise Exception('wrong status code: {0}'.format(request
.status_code
))
416 id = self
.getAspectID(aspect_name
)
417 return Aspect(self
._connection
, id)
419 def remove(self
, id=-1, name
=''):
420 """This method removes an aspect.
421 You can give it either id or name of the aspect.
422 When both are specified, id takes precedence over name.
424 Status code 500 is accepted because although the D* will
425 go nuts it will remove the aspect anyway.
427 :param aspect_id: id fo aspect to remove
429 :param name: name of aspect to remove
432 if id == -1 and name
: id = self
.getAspectID(name
)
433 data
= {'_method': 'delete',
434 'authenticity_token': repr(self
._connection
)}
435 request
= self
._connection
.post('aspects/{0}'.format(id), data
=data
)
436 if request
.status_code
not in [200, 302, 500]:
437 raise Exception('wrong status code: {0}: cannot remove aspect'.format(request
.status_code
))
440 class Commented(Generic
):
441 """This stream contains all posts
442 the user has made a comment on.
444 _location
= 'commented.json'
447 class Liked(Generic
):
448 """This stream contains all posts the user liked.
450 _location
= 'liked.json'
453 class Mentions(Generic
):
454 """This stream contains all posts
455 the user is mentioned in.
457 _location
= 'mentions.json'
460 class FollowedTags(Generic
):
461 """This stream contains all posts
462 containing tags the user is following.
464 _location
= 'followed_tags.json'
467 """Returns list of followed tags.
471 def remove(self
, tag_id
):
472 """Stop following a tag.
474 :param tag_id: tag id
477 data
= {'authenticity_token': self
._connection
.get_token()}
478 request
= self
._connection
.delete('tag_followings/{0}'.format(tag_id
), data
=data
)
479 if request
.status_code
!= 404:
480 raise Exception('wrong status code: {0}'.format(request
.status_code
))
482 def add(self
, tag_name
):
484 Error code 403 is accepted because pods respod with it when request
485 is sent to follow a tag that a user already follows.
487 :param tag_name: tag name
489 :returns: int (response code)
491 data
= {'name': tag_name
,
492 'authenticity_token': repr(self
._connection
),
494 headers
= {'content-type': 'application/json',
495 'x-csrf-token': repr(self
._connection
),
496 'accept': 'application/json'
499 request
= self
._connection
.post('tag_followings', data
=json
.dumps(data
), headers
=headers
)
501 if request
.status_code
not in [201, 403]:
502 raise Exception('wrong error code: {0}'.format(request
.status_code
))
503 return request
.status_code
507 """This stream contains all posts containing a tag.
509 def __init__(self
, connection
, tag
, fetch
=True):
511 :param connection: Connection() object
512 :type connection: diaspy.connection.Connection
516 self
._connection
= connection
517 self
._location
= 'tags/{0}.json'.format(tag
)
518 if fetch
: self
.fill()