Merge branch 'master' into async_streaming

This commit is contained in:
Lorenz Diener 2017-09-08 15:02:58 +02:00 cometido por GitHub
commit 7de02fe5b8
S'han modificat 6 arxius amb 247 adicions i 184 eliminacions

Veure arxiu

@ -6,7 +6,6 @@ import mimetypes
import time
import random
import string
import pytz
import datetime
from contextlib import closing
import pytz
@ -18,6 +17,7 @@ import re
import copy
import threading
class Mastodon:
"""
Super basic but thorough and easy to use Mastodon
@ -29,12 +29,12 @@ class Mastodon:
__DEFAULT_BASE_URL = 'https://mastodon.social'
__DEFAULT_TIMEOUT = 300
###
# Registering apps
###
@staticmethod
def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, website = None, to_file = None, api_base_url = __DEFAULT_BASE_URL, request_timeout = __DEFAULT_TIMEOUT):
def create_app(client_name, scopes=['read', 'write', 'follow'], redirect_uris=None, website=None, to_file=None,
api_base_url=__DEFAULT_BASE_URL, request_timeout=__DEFAULT_TIMEOUT):
"""
Create a new app with given client_name and scopes (read, write, follow)
@ -56,9 +56,9 @@ class Mastodon:
try:
if redirect_uris is not None:
request_data['redirect_uris'] = redirect_uris;
request_data['redirect_uris'] = redirect_uris
else:
request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob';
request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
if website is not None:
request_data['website'] = website
@ -67,7 +67,7 @@ class Mastodon:
except Exception as e:
raise MastodonNetworkError("Could not complete request: %s" % e)
if to_file != None:
if to_file is not None:
with open(to_file, 'w') as secret_file:
secret_file.write(response['client_id'] + '\n')
secret_file.write(response['client_secret'] + '\n')
@ -77,7 +77,10 @@ class Mastodon:
###
# Authentication, including constructor
###
def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL, debug_requests = False, ratelimit_method = "wait", ratelimit_pacefactor = 1.1, request_timeout = __DEFAULT_TIMEOUT):
def __init__(self, client_id, client_secret=None, access_token=None,
api_base_url=__DEFAULT_BASE_URL, debug_requests=False,
ratelimit_method="wait", ratelimit_pacefactor=1.1,
request_timeout=__DEFAULT_TIMEOUT):
"""
Create a new API wrapper instance based on the given client_secret and client_id. If you
give a client_id and it is not a file, you must also give a secret.
@ -116,7 +119,7 @@ class Mastodon:
self.request_timeout = request_timeout
if not ratelimit_method in ["throw", "wait", "pace"]:
if ratelimit_method not in ["throw", "wait", "pace"]:
raise MastodonIllegalArgumentError("Invalid ratelimit method.")
if os.path.isfile(self.client_id):
@ -124,15 +127,15 @@ class Mastodon:
self.client_id = secret_file.readline().rstrip()
self.client_secret = secret_file.readline().rstrip()
else:
if self.client_secret == None:
if self.client_secret is None:
raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
if self.access_token != None and os.path.isfile(self.access_token):
if self.access_token is not None and os.path.isfile(self.access_token):
with open(self.access_token, 'r') as token_file:
self.access_token = token_file.readline().rstrip()
def auth_request_url(self, client_id = None, redirect_uris = "urn:ietf:wg:oauth:2.0:oob", scopes = ['read', 'write', 'follow']):
def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob",
scopes=['read', 'write', 'follow']):
"""Returns the url that a client needs to request the grant from the server.
"""
if client_id is None:
@ -142,7 +145,7 @@ class Mastodon:
with open(client_id, 'r') as secret_file:
client_id = secret_file.readline().rstrip()
params = {}
params = dict()
params['client_id'] = client_id
params['response_type'] = "code"
params['redirect_uri'] = redirect_uris
@ -150,8 +153,8 @@ class Mastodon:
formatted_params = urlencode(params)
return "".join([self.api_base_url, "/oauth/authorize?", formatted_params])
def log_in(self, username = None, password = None,\
code = None, redirect_uri = "urn:ietf:wg:oauth:2.0:oob", refresh_token = None,\
def log_in(self, username=None, password=None,
code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None,
scopes=['read', 'write', 'follow'], to_file=None):
"""
Your username is the e-mail you use to log in into mastodon.
@ -165,7 +168,8 @@ class Mastodon:
Will throw a MastodonIllegalArgumentError if username / password
are wrong, scopes are not valid or granted scopes differ from requested.
For OAuth2 documentation, compare https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper
For OAuth2 documentation, compare
https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper
Returns the access token.
"""
@ -202,9 +206,10 @@ class Mastodon:
received_scopes = " ".join(sorted(response["scope"].split(" ")))
if requested_scopes != received_scopes:
raise MastodonAPIError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".')
raise MastodonAPIError(
'Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".')
if to_file != None:
if to_file is not None:
with open(to_file, 'w') as token_file:
token_file.write(response['access_token'] + '\n')
@ -240,7 +245,8 @@ class Mastodon:
params_initial['local'] = True
params = self.__generate_params(params_initial, ['timeline'])
return self.__api_request('GET', '/api/v1/timelines/' + timeline, params)
url = '/api/v1/timelines/{0}'.format(timeline)
return self.__api_request('GET', url, params)
def timeline_home(self, max_id=None, since_id=None, limit=None):
"""
@ -248,7 +254,8 @@ class Mastodon:
Returns a list of toot dicts.
"""
return self.timeline('home', max_id = max_id, since_id = since_id, limit = limit)
return self.timeline('home', max_id=max_id, since_id=since_id,
limit=limit)
def timeline_local(self, max_id=None, since_id=None, limit=None):
"""
@ -256,7 +263,8 @@ class Mastodon:
Returns a list of toot dicts.
"""
return self.timeline('local', max_id = max_id, since_id = since_id, limit = limit)
return self.timeline('local', max_id=max_id, since_id=since_id,
limit=limit)
def timeline_public(self, max_id=None, since_id=None, limit=None):
"""
@ -264,7 +272,8 @@ class Mastodon:
Returns a list of toot dicts.
"""
return self.timeline('public', max_id = max_id, since_id = since_id, limit = limit)
return self.timeline('public', max_id=max_id, since_id=since_id,
limit=limit)
def timeline_hashtag(self, hashtag, max_id=None, since_id=None, limit=None):
"""
@ -272,7 +281,8 @@ class Mastodon:
Returns a list of toot dicts.
"""
return self.timeline('tag/' + str(hashtag), max_id = max_id, since_id = since_id, limit = limit)
url = 'tag/{0}'.format(str(hashtag))
return self.timeline(url, max_id=max_id, since_id=since_id, limit=limit)
###
# Reading data: Statuses
@ -283,7 +293,8 @@ class Mastodon:
Returns a toot dict.
"""
return self.__api_request('GET', '/api/v1/statuses/' + str(id))
url = '/api/v1/statuses/{0}'.format(str(id))
return self.__api_request('GET', url)
def status_card(self, id):
"""
@ -292,7 +303,8 @@ class Mastodon:
Returns a card dict.
"""
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/card')
url = '/api/v1/statuses/{0}/card'.format(str(id))
return self.__api_request('GET', url)
def status_context(self, id):
"""
@ -300,7 +312,8 @@ class Mastodon:
Returns a context dict.
"""
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context')
url = '/api/v1/statuses/{0}/context'.format(str(id))
return self.__api_request('GET', url)
def status_reblogged_by(self, id):
"""
@ -308,7 +321,8 @@ class Mastodon:
Returns a list of user dicts.
"""
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by')
url = '/api/v1/statuses/{0}/reblogged_by'.format(str(id))
return self.__api_request('GET', url)
def status_favourited_by(self, id):
"""
@ -316,7 +330,8 @@ class Mastodon:
Returns a list of user dicts.
"""
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by')
url = '/api/v1/statuses/{0}/favourited_by'.format(str(id))
return self.__api_request('GET', url)
###
# Reading data: Notifications
@ -330,11 +345,12 @@ class Mastodon:
Returns a list of notification dicts.
"""
if id == None:
if id is None:
params = self.__generate_params(locals(), ['id'])
return self.__api_request('GET', '/api/v1/notifications', params)
else:
return self.__api_request('GET', '/api/v1/notifications/' + str(id))
url = '/api/v1/notifications/{0}'.format(str(id))
return self.__api_request('GET', url)
###
# Reading data: Accounts
@ -345,7 +361,8 @@ class Mastodon:
Returns a user dict.
"""
return self.__api_request('GET', '/api/v1/accounts/' + str(id))
url = '/api/v1/accounts/{0}'.format(str(id))
return self.__api_request('GET', url)
def account_verify_credentials(self):
"""
@ -362,7 +379,8 @@ class Mastodon:
Returns a list of toot dicts.
"""
params = self.__generate_params(locals(), ['id'])
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses', params)
url = '/api/v1/accounts/{0}/statuses'.format(str(id))
return self.__api_request('GET', url, params)
def account_following(self, id, max_id=None, since_id=None, limit=None):
"""
@ -371,7 +389,8 @@ class Mastodon:
Returns a list of user dicts.
"""
params = self.__generate_params(locals(), ['id'])
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following', params)
url = '/api/v1/accounts/{0}/following'.format(str(id))
return self.__api_request('GET', url, params)
def account_followers(self, id, max_id=None, since_id=None, limit=None):
"""
@ -380,7 +399,8 @@ class Mastodon:
Returns a list of user dicts.
"""
params = self.__generate_params(locals(), ['id'])
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers', params)
url = '/api/v1/accounts/{0}/followers'.format(str(id))
return self.__api_request('GET', url, params)
def account_relationships(self, id):
"""
@ -390,7 +410,8 @@ class Mastodon:
Returns a list of relationship dicts.
"""
params = self.__generate_params(locals())
return self.__api_request('GET', '/api/v1/accounts/relationships', params)
return self.__api_request('GET', '/api/v1/accounts/relationships',
params)
def account_search(self, q, limit=None):
"""
@ -486,7 +507,8 @@ class Mastodon:
###
# Writing data: Statuses
###
def status_post(self, status, in_reply_to_id = None, media_ids = None, sensitive = False, visibility = '', spoiler_text = None):
def status_post(self, status, in_reply_to_id=None, media_ids=None,
sensitive=False, visibility='', spoiler_text=None):
"""
Post a status. Can optionally be in reply to another status and contain
up to four pieces of media (Uploaded via media_post()). media_ids can
@ -505,7 +527,9 @@ class Mastodon:
'public' - post will be public
If not passed in, visibility defaults to match the current account's
privacy setting (private if the account is locked, public otherwise).
locked setting (private if the account is locked, public otherwise).
Note that the "privacy" setting is not currently used in determining
visibility when not specified.
The spoiler_text parameter is a string to be shown as a warning before
the text of the status. If no text is passed in, no warning will be
@ -518,12 +542,13 @@ class Mastodon:
# Validate visibility parameter
valid_visibilities = ['private', 'public', 'unlisted', 'direct', '']
if params_initial['visibility'].lower() not in valid_visibilities:
raise ValueError('Invalid visibility value! Acceptable values are %s' % valid_visibilities)
raise ValueError('Invalid visibility value! Acceptable '
'values are %s' % valid_visibilities)
if params_initial['sensitive'] == False:
if params_initial['sensitive'] is False:
del [params_initial['sensitive']]
if media_ids != None:
if media_ids is not None:
try:
media_ids_proper = []
for media_id in media_ids:
@ -532,7 +557,8 @@ class Mastodon:
else:
media_ids_proper.append(media_id)
except Exception as e:
raise MastodonIllegalArgumentError("Invalid media dict: %s" % e)
raise MastodonIllegalArgumentError("Invalid media "
"dict: %s" % e)
params_initial["media_ids"] = media_ids_proper
@ -543,6 +569,8 @@ class Mastodon:
"""
Synonym for status_post that only takes the status text as input.
Usage in production code is not recommended.
Returns a toot dict with the new status.
"""
return self.status_post(status)
@ -553,14 +581,16 @@ class Mastodon:
Returns an empty dict for good measure.
"""
return self.__api_request('DELETE', '/api/v1/statuses/' + str(id))
url = '/api/v1/statuses/{0}'.format(str(id))
return self.__api_request('DELETE', url)
def status_reblog(self, id):
"""Reblog a status.
Returns a toot with with a new status that wraps around the reblogged one.
"""
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog")
url = '/api/v1/statuses/{0}/reblog'.format(str(id))
return self.__api_request('POST', url)
def status_unreblog(self, id):
"""
@ -568,7 +598,8 @@ class Mastodon:
Returns a toot dict with the status that used to be reblogged.
"""
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog")
url = '/api/v1/statuses/{0}/unreblog'.format(str(id))
return self.__api_request('POST', url)
def status_favourite(self, id):
"""
@ -576,7 +607,8 @@ class Mastodon:
Returns a toot dict with the favourited status.
"""
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite")
url = '/api/v1/statuses/{0}/favourite'.format(str(id))
return self.__api_request('POST', url)
def status_unfavourite(self, id):
"""
@ -584,7 +616,8 @@ class Mastodon:
Returns a toot dict with the un-favourited status.
"""
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite")
url = '/api/v1/statuses/{0}/unfavourite'.format(str(id))
return self.__api_request('POST', url)
###
# Writing data: Notifications
@ -604,7 +637,8 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow")
url = '/api/v1/accounts/{0}/follow'.format(str(id))
return self.__api_request('POST', url)
def follows(self, uri):
"""
@ -621,7 +655,8 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow")
url = '/api/v1/accounts/{0}/unfollow'.format(str(id))
return self.__api_request('POST', url)
def account_block(self, id):
"""
@ -629,7 +664,8 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block")
url = '/api/v1/accounts/{0}/block'.format(str(id))
return self.__api_request('POST', url)
def account_unblock(self, id):
"""
@ -637,7 +673,8 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock")
url = '/api/v1/accounts/{0}/unblock'.format(str(id))
return self.__api_request('POST', url)
def account_mute(self, id):
"""
@ -645,7 +682,8 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/mute")
url = '/api/v1/accounts/{0}/mute'.format(str(id))
return self.__api_request('POST', url)
def account_unmute(self, id):
"""
@ -653,9 +691,11 @@ class Mastodon:
Returns a relationship dict containing the updated relationship to the user.
"""
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unmute")
url = '/api/v1/accounts/{0}/unmute'.format(str(id))
return self.__api_request('POST', url)
def account_update_credentials(self, display_name = None, note = None, avatar = None, header = None):
def account_update_credentials(self, display_name=None, note=None,
avatar=None, header=None):
"""
Update the profile for the currently authenticated user.
@ -690,7 +730,8 @@ class Mastodon:
Returns an empty dict.
"""
return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/authorize")
url = '/api/v1/follow_requests/{0}/authorize'.format(str(id))
return self.__api_request('POST', url)
def follow_request_reject(self, id):
"""
@ -698,7 +739,8 @@ class Mastodon:
Returns an empty dict.
"""
return self.__api_request('POST', '/api/v1/follow_requests/' + str(id) + "/reject")
url = '/api/v1/follow_requests/{0}/reject'.format(str(id))
return self.__api_request('POST', url)
###
# Writing data: Media
@ -716,18 +758,22 @@ class Mastodon:
Returns a media dict. This contains the id that can be used in
status_post to attach the media file to a toot.
"""
if mime_type == None and os.path.isfile(media_file):
if mime_type is None and os.path.isfile(media_file):
mime_type = mimetypes.guess_type(media_file)[0]
media_file = open(media_file, 'rb')
if mime_type == None:
raise MastodonIllegalArgumentError('Could not determine mime type or data passed directly without mime type.')
if mime_type is None:
raise MastodonIllegalArgumentError('Could not determine mime type'
' or data passed directly '
'without mime type.')
random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type)
file_name = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(
mime_type)
media_file_description = (file_name, media_file, mime_type)
return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description})
return self.__api_request('POST', '/api/v1/media',
files={'file': media_file_description})
###
# Writing data: Domain blocks
@ -759,11 +805,11 @@ class Mastodon:
"""
if isinstance(previous_page, list):
if '_pagination_next' in previous_page[-1]:
params = previous_page[-1]['_pagination_next']
params = copy.deepcopy(previous_page[-1]['_pagination_next'])
else:
return None
else:
params = previous_page
params = copy.deepcopy(previous_page)
method = params['_pagination_method']
del params['_pagination_method']
@ -782,12 +828,12 @@ class Mastodon:
Returns the previous page or None if no further data is available.
"""
if isinstance(next_page, list):
if '_pagination_prev' in next_page[-1]:
params = next_page[-1]['_pagination_prev']
if '_pagination_prev' in next_page[0]:
params = copy.deepcopy(next_page[0]['_pagination_prev'])
else:
return None
else:
params = next_page
params = copy.deepcopy(next_page)
method = params['_pagination_method']
del params['_pagination_method']
@ -810,7 +856,7 @@ class Mastodon:
all_pages = []
current_page = first_page
while current_page != None:
while current_page is not None and len(current_page) > 0:
all_pages.extend(current_page)
current_page = self.fetch_next(current_page)
@ -871,8 +917,7 @@ class Mastodon:
will return a handle corresponding to the open connection. The
connection may be closed at any time by calling its close() method.
"""
return self.__stream('/api/v1/streaming/hashtag', listener, params={'tag': tag}, async=async)
return self.__stream("/api/v1/streaming/hashtag?tag={}".format(tag), listener)
###
# Internal helpers, dragons probably
###
@ -884,7 +929,7 @@ class Mastodon:
Assumes UTC if timezone is not given.
"""
date_time_utc = None
if date_time.tzinfo == None:
if date_time.tzinfo is None:
date_time_utc = date_time.replace(tzinfo=pytz.utc)
else:
date_time_utc = date_time.astimezone(pytz.utc)
@ -899,7 +944,7 @@ class Mastodon:
"""
response = None
headers = None
remaining_wait = 0
# "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it
# would take to not hit the rate limit at that request rate.
if do_ratelimiting and self.ratelimit_method == "pace":
@ -920,10 +965,10 @@ class Mastodon:
time.sleep(to_next)
# Generate request headers
if self.access_token != None:
if self.access_token is not None:
headers = {'Authorization': 'Bearer ' + self.access_token}
if self.debug_requests == True:
if self.debug_requests:
print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".')
print('Parameters: ' + str(params))
print('Headers: ' + str(headers))
@ -937,24 +982,28 @@ class Mastodon:
response_object = None
try:
if method == 'GET':
response_object = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout)
response_object = requests.get(self.api_base_url + endpoint, params=params,
headers=headers, files=files,
timeout=self.request_timeout)
if method == 'POST':
response_object = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout)
response_object = requests.post(self.api_base_url + endpoint, data=params, headers=headers,
files=files, timeout=self.request_timeout)
if method == 'PATCH':
response_object = requests.patch(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout)
response_object = requests.patch(self.api_base_url + endpoint, data=params, headers=headers,
files=files, timeout=self.request_timeout)
if method == 'DELETE':
response_object = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files, timeout = self.request_timeout)
response_object = requests.delete(self.api_base_url + endpoint, data=params, headers=headers,
files=files, timeout=self.request_timeout)
except Exception as e:
raise MastodonNetworkError("Could not complete request: %s" % e)
if response_object == None:
if response_object is None:
raise MastodonIllegalArgumentError("Illegal request.")
# Handle response
if self.debug_requests == True:
if self.debug_requests:
print('Mastodon: Response received with code ' + str(response_object.status_code) + '.')
print('response headers: ' + str(response_object.headers))
print('Response text content: ' + str(response_object.text))
@ -968,12 +1017,21 @@ class Mastodon:
try:
response = response_object.json()
except:
raise MastodonAPIError("Could not parse response as JSON, response code was %s, bad json content was '%s'" % (response_object.status_code, response_object.content))
raise MastodonAPIError(
"Could not parse response as JSON, response code was %s, "
"bad json content was '%s'" % (response_object.status_code,
response_object.content))
# Parse link headers
if isinstance(response, list) and 'Link' in response_object.headers and response_object.headers['Link'] != "":
tmp_urls = requests.utils.parse_header_links(response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
if isinstance(response, list) and \
'Link' in response_object.headers and \
response_object.headers['Link'] != "":
tmp_urls = requests.utils.parse_header_links(
response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
for url in tmp_urls:
if 'rel' not in url:
continue
if url['rel'] == 'next':
# Be paranoid and extract max_id specifically
next_url = url['url']
@ -984,6 +1042,8 @@ class Mastodon:
next_params['_pagination_method'] = method
next_params['_pagination_endpoint'] = endpoint
next_params['max_id'] = int(matchgroups.group(1))
if "since_id" in next_params:
del next_params['since_id']
response[-1]['_pagination_next'] = next_params
if url['rel'] == 'prev':
@ -995,7 +1055,9 @@ class Mastodon:
prev_params = copy.deepcopy(params)
prev_params['_pagination_method'] = method
prev_params['_pagination_endpoint'] = endpoint
prev_params['max_id'] = int(matchgroups.group(1))
prev_params['since_id'] = int(matchgroups.group(1))
if "max_id" in prev_params:
del prev_params['max_id']
response[0]['_pagination_prev'] = prev_params
# Handle rate limiting
@ -1031,7 +1093,6 @@ class Mastodon:
return response
def __stream(self, endpoint, listener, params={}, async=False):
"""
Internal streaming API helper.
@ -1041,7 +1102,7 @@ class Mastodon:
"""
headers = {}
if self.access_token != None:
if self.access_token is not None:
headers = {'Authorization': 'Bearer ' + self.access_token}
url = self.api_base_url + endpoint
@ -1088,7 +1149,7 @@ class Mastodon:
del params['self']
param_keys = list(params.keys())
for key in param_keys:
if params[key] == None or key in exclude:
if params[key] is None or key in exclude:
del params[key]
param_keys = list(params.keys())
@ -1099,13 +1160,9 @@ class Mastodon:
return params
def __get_token_expired(self):
"""Internal helper for oauth code"""
if self._token_expired < datetime.datetime.now():
return True
else:
return False
return self._token_expired < datetime.datetime.now()
def __set_token_expired(self, value):
"""Internal helper for oauth code"""
@ -1126,23 +1183,30 @@ class Mastodon:
"""Internal add-protocol-to-url helper"""
if not base_url.startswith("http://") and not base_url.startswith("https://"):
base_url = "https://" + base_url
# Some API endpoints can't handle extra /'s in path requests
base_url = base_url.rstrip("/")
return base_url
##
# Exceptions
##
class MastodonIllegalArgumentError(ValueError):
pass
class MastodonFileNotFoundError(IOError):
pass
class MastodonNetworkError(IOError):
pass
class MastodonAPIError(Exception):
pass
class MastodonRatelimitError(Exception):
pass

Veure arxiu

@ -1,7 +1,7 @@
'''
"""
Handlers for the Streaming API:
https://github.com/tootsuite/mastodon/blob/master/docs/Using-the-API/Streaming-API.md
'''
"""
import json
import logging
@ -12,43 +12,43 @@ log = logging.getLogger(__name__)
class MalformedEventError(Exception):
'''Raised when the server-sent event stream is malformed.'''
"""Raised when the server-sent event stream is malformed."""
pass
class StreamListener(object):
'''Callbacks for the streaming API. Create a subclass, override the on_xxx
"""Callbacks for the streaming API. Create a subclass, override the on_xxx
methods for the kinds of events you're interested in, then pass an instance
of your subclass to Mastodon.user_stream(), Mastodon.public_stream(), or
Mastodon.hashtag_stream().'''
Mastodon.hashtag_stream()."""
def on_update(self, status):
'''A new status has appeared! 'status' is the parsed JSON dictionary
describing the status.'''
"""A new status has appeared! 'status' is the parsed JSON dictionary
describing the status."""
pass
def on_notification(self, notification):
'''A new notification. 'notification' is the parsed JSON dictionary
describing the notification.'''
"""A new notification. 'notification' is the parsed JSON dictionary
describing the notification."""
pass
def on_delete(self, status_id):
'''A status has been deleted. status_id is the status' integer ID.'''
"""A status has been deleted. status_id is the status' integer ID."""
pass
def handle_heartbeat(self):
'''The server has sent us a keep-alive message. This callback may be
"""The server has sent us a keep-alive message. This callback may be
useful to carry out periodic housekeeping tasks, or just to confirm
that the connection is still open.'''
that the connection is still open."""
def handle_stream(self, lines):
'''
"""
Handles a stream of events from the Mastodon server. When each event
is received, the corresponding .on_[name]() method is called.
lines: an iterable of lines of bytes sent by the Mastodon server, as
returned by requests.Response.iter_lines().
'''
"""
event = {}
for raw_line in lines:
try:
@ -104,4 +104,3 @@ class StreamListener(object):
else:
# TODO: allow handlers to return/raise to stop streaming cleanly
handler(payload)

Veure arxiu

@ -1,4 +1,4 @@
from setuptools import setup, find_packages
from setuptools import setup
setup(name='Mastodon.py',
version='1.0.8',
@ -6,7 +6,7 @@ setup(name='Mastodon.py',
packages=['mastodon'],
setup_requires=['pytest-runner'],
tests_require=['pytest'],
install_requires=['requests', 'dateutil', 'six'],
install_requires=['requests', 'python-dateutil', 'six', 'pytz'],
url='https://github.com/halcy/Mastodon.py',
author='Lorenz Diener',
author_email='lorenzd+mastodonpypypi@gmail.com',
@ -19,5 +19,4 @@ setup(name='Mastodon.py',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3',
]
)
])

0
tests/__init__.py Normal file
Veure arxiu

Veure arxiu

@ -24,9 +24,10 @@ class Listener(StreamListener):
self.heartbeats += 1
def handle_stream_(self, lines):
'''Test helper to avoid littering all tests with six.b().'''
"""Test helper to avoid littering all tests with six.b()."""
return self.handle_stream(map(six.b, lines))
def test_heartbeat():
listener = Listener()
listener.handle_stream_([':one', ':two'])
@ -85,7 +86,7 @@ def test_many(events):
def test_unknown_event():
'''Be tolerant of new event types'''
"""Be tolerant of new event types"""
listener = Listener()
listener.handle_stream_([
'event: blahblah',
@ -137,11 +138,11 @@ def test_sse_order_doesnt_matter():
def test_extra_keys_ignored():
'''
"""
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format
defines 'id' and 'retry' keys which the Mastodon streaming API doesn't use,
and alleges that "All other field names are ignored".
'''
"""
listener = Listener()
listener.handle_stream_([
'event: update',
@ -155,7 +156,7 @@ def test_extra_keys_ignored():
def test_valid_utf8():
'''Snowman Cat Face With Tears Of Joy'''
"""Snowman Cat Face With Tears Of Joy"""
listener = Listener()
listener.handle_stream_([
'event: update',
@ -166,7 +167,7 @@ def test_valid_utf8():
def test_invalid_utf8():
'''Cat Face With Tears O'''
"""Cat Face With Tears O"""
listener = Listener()
with pytest.raises(MalformedEventError):
listener.handle_stream_([
@ -177,13 +178,13 @@ def test_invalid_utf8():
def test_multiline_payload():
'''
"""
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Data-only_messages
says that newlines in the 'data' field can be encoded by sending the field
twice! This would be really pathological for Mastodon because the payload
is JSON, but technically literal newlines are permissible (outside strings)
so let's handle this case.
'''
"""
listener = Listener()
listener.handle_stream_([
'event: update',