diff options
-rw-r--r-- | Pipfile | 1 | ||||
-rw-r--r-- | Pipfile.lock | 8 | ||||
-rw-r--r-- | docs/index.rst | 25 | ||||
-rw-r--r-- | mastodon/Mastodon.py | 29 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | tests/test_errors.py | 20 | ||||
-rw-r--r-- | tests/test_pagination.py | 20 |
7 files changed, 95 insertions, 10 deletions
@@ -13,3 +13,4 @@ pytest-cov = "*" | |||
13 | vcrpy = "*" | 13 | vcrpy = "*" |
14 | pytest-vcr = "<1" | 14 | pytest-vcr = "<1" |
15 | pytest-mock = "*" | 15 | pytest-mock = "*" |
16 | requests-mock = "*" | ||
diff --git a/Pipfile.lock b/Pipfile.lock index d1939fc..db1193b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock | |||
@@ -313,6 +313,14 @@ | |||
313 | ], | 313 | ], |
314 | "version": "==2.20.1" | 314 | "version": "==2.20.1" |
315 | }, | 315 | }, |
316 | "requests-mock": { | ||
317 | "hashes": [ | ||
318 | "sha256:7a5fa99db5e3a2a961b6f20ed40ee6baeff73503cf0a553cc4d679409e6170fb", | ||
319 | "sha256:8ca0628dc66d3f212878932fd741b02aa197ad53fd2228164800a169a4a826af" | ||
320 | ], | ||
321 | "index": "pypi", | ||
322 | "version": "==1.5.2" | ||
323 | }, | ||
316 | "six": { | 324 | "six": { |
317 | "hashes": [ | 325 | "hashes": [ |
318 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", | 326 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", |
diff --git a/docs/index.rst b/docs/index.rst index 808e563..515e427 100644 --- a/docs/index.rst +++ b/docs/index.rst | |||
@@ -71,6 +71,26 @@ for applications that need to handle all rate limiting themselves (i.e. interact | |||
71 | or applications wanting to use Mastodon.py in a multi-threaded context ("wait" and "pace" | 71 | or applications wanting to use Mastodon.py in a multi-threaded context ("wait" and "pace" |
72 | modes are not thread safe). | 72 | modes are not thread safe). |
73 | 73 | ||
74 | .. note:: | ||
75 | Rate limit information is available on the `Mastodon` object for applications that | ||
76 | implement their own rate limit handling. | ||
77 | |||
78 | .. attribute:: Mastodon.ratelimit_remaining | ||
79 | |||
80 | Number of requests allowed until the next reset. | ||
81 | |||
82 | .. attribute:: Mastodon.ratelimit_reset | ||
83 | |||
84 | Time at which the rate limit will next be reset, as a POSIX timestamp. | ||
85 | |||
86 | .. attribute:: Mastodon.ratelimit_limit | ||
87 | |||
88 | Total number of requests allowed between resets. Typically 300. | ||
89 | |||
90 | .. attribute:: Mastodon.ratelimit_lastcall | ||
91 | |||
92 | Time at which these values have last been seen and updated, as a POSIX timestamp. | ||
93 | |||
74 | In "wait" mode, once a request hits the rate limit, Mastodon.py will wait until | 94 | In "wait" mode, once a request hits the rate limit, Mastodon.py will wait until |
75 | the rate limit resets and then try again, until the request succeeds or an error | 95 | the rate limit resets and then try again, until the request succeeds or an error |
76 | is encountered. This mode is for applications that would rather just not worry about rate limits | 96 | is encountered. This mode is for applications that would rather just not worry about rate limits |
@@ -91,9 +111,8 @@ minute time slot, and tighter limits on logins. Mastodon.py does not make any ef | |||
91 | to respect these. | 111 | to respect these. |
92 | 112 | ||
93 | If your application requires many hits to endpoints that are available without logging | 113 | If your application requires many hits to endpoints that are available without logging |
94 | in, do consider using Mastodon.py without authenticating to get the full per-IP limit. In | 114 | in, do consider using Mastodon.py without authenticating to get the full per-IP limit. |
95 | this case, you can set the Mastodon objects `ratelimit_limit` and `ratelimit_remaining` | 115 | |
96 | properties appropriately if you want to use advanced rate limit handling. | ||
97 | 116 | ||
98 | A note about pagination | 117 | A note about pagination |
99 | ----------------------- | 118 | ----------------------- |
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index f78d303..dce2081 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -2222,9 +2222,12 @@ class Mastodon: | |||
2222 | if not response_object.ok: | 2222 | if not response_object.ok: |
2223 | try: | 2223 | try: |
2224 | response = response_object.json(object_hook=self.__json_hooks) | 2224 | response = response_object.json(object_hook=self.__json_hooks) |
2225 | if not isinstance(response, dict) or 'error' not in response: | 2225 | if isinstance(response, dict) and 'error' in response: |
2226 | error_msg = response['error'] | ||
2227 | elif isinstance(response, str): | ||
2228 | error_msg = response | ||
2229 | else: | ||
2226 | error_msg = None | 2230 | error_msg = None |
2227 | error_msg = response['error'] | ||
2228 | except ValueError: | 2231 | except ValueError: |
2229 | error_msg = None | 2232 | error_msg = None |
2230 | 2233 | ||
@@ -2250,6 +2253,8 @@ class Mastodon: | |||
2250 | # on any 404 | 2253 | # on any 404 |
2251 | elif response_object.status_code == 401: | 2254 | elif response_object.status_code == 401: |
2252 | ex_type = MastodonUnauthorizedError | 2255 | ex_type = MastodonUnauthorizedError |
2256 | elif response_object.status_code == 502: | ||
2257 | ex_type = MastodonServerError | ||
2253 | else: | 2258 | else: |
2254 | ex_type = MastodonAPIError | 2259 | ex_type = MastodonAPIError |
2255 | 2260 | ||
@@ -2280,13 +2285,17 @@ class Mastodon: | |||
2280 | if url['rel'] == 'next': | 2285 | if url['rel'] == 'next': |
2281 | # Be paranoid and extract max_id specifically | 2286 | # Be paranoid and extract max_id specifically |
2282 | next_url = url['url'] | 2287 | next_url = url['url'] |
2283 | matchgroups = re.search(r"max_id=([0-9]*)", next_url) | 2288 | matchgroups = re.search(r"[?&]max_id=([^&]+)", next_url) |
2284 | 2289 | ||
2285 | if matchgroups: | 2290 | if matchgroups: |
2286 | next_params = copy.deepcopy(params) | 2291 | next_params = copy.deepcopy(params) |
2287 | next_params['_pagination_method'] = method | 2292 | next_params['_pagination_method'] = method |
2288 | next_params['_pagination_endpoint'] = endpoint | 2293 | next_params['_pagination_endpoint'] = endpoint |
2289 | next_params['max_id'] = int(matchgroups.group(1)) | 2294 | max_id = matchgroups.group(1) |
2295 | if max_id.isdigit(): | ||
2296 | next_params['max_id'] = int(max_id) | ||
2297 | else: | ||
2298 | next_params['max_id'] = max_id | ||
2290 | if "since_id" in next_params: | 2299 | if "since_id" in next_params: |
2291 | del next_params['since_id'] | 2300 | del next_params['since_id'] |
2292 | response[-1]._pagination_next = next_params | 2301 | response[-1]._pagination_next = next_params |
@@ -2294,13 +2303,17 @@ class Mastodon: | |||
2294 | if url['rel'] == 'prev': | 2303 | if url['rel'] == 'prev': |
2295 | # Be paranoid and extract since_id specifically | 2304 | # Be paranoid and extract since_id specifically |
2296 | prev_url = url['url'] | 2305 | prev_url = url['url'] |
2297 | matchgroups = re.search(r"since_id=([0-9]*)", prev_url) | 2306 | matchgroups = re.search(r"[?&]since_id=([^&]+)", prev_url) |
2298 | 2307 | ||
2299 | if matchgroups: | 2308 | if matchgroups: |
2300 | prev_params = copy.deepcopy(params) | 2309 | prev_params = copy.deepcopy(params) |
2301 | prev_params['_pagination_method'] = method | 2310 | prev_params['_pagination_method'] = method |
2302 | prev_params['_pagination_endpoint'] = endpoint | 2311 | prev_params['_pagination_endpoint'] = endpoint |
2303 | prev_params['since_id'] = int(matchgroups.group(1)) | 2312 | since_id = matchgroups.group(1) |
2313 | if since_id.isdigit(): | ||
2314 | prev_params['since_id'] = int(since_id) | ||
2315 | else: | ||
2316 | prev_params['since_id'] = since_id | ||
2304 | if "max_id" in prev_params: | 2317 | if "max_id" in prev_params: |
2305 | del prev_params['max_id'] | 2318 | del prev_params['max_id'] |
2306 | response[0]._pagination_prev = prev_params | 2319 | response[0]._pagination_prev = prev_params |
@@ -2536,6 +2549,10 @@ class MastodonAPIError(MastodonError): | |||
2536 | """Raised when the mastodon API generates a response that cannot be handled""" | 2549 | """Raised when the mastodon API generates a response that cannot be handled""" |
2537 | pass | 2550 | pass |
2538 | 2551 | ||
2552 | class MastodonServerError(MastodonError): | ||
2553 | """Raised if the Server is malconfigured, e.g. returns a 502 error code""" | ||
2554 | pass | ||
2555 | |||
2539 | class MastodonNotFoundError(MastodonAPIError): | 2556 | class MastodonNotFoundError(MastodonAPIError): |
2540 | """Raised when the mastodon API returns a 404 Not Found error""" | 2557 | """Raised when the mastodon API returns a 404 Not Found error""" |
2541 | pass | 2558 | pass |
@@ -1,6 +1,6 @@ | |||
1 | from setuptools import setup | 1 | from setuptools import setup |
2 | 2 | ||
3 | test_deps = ['pytest', 'pytest-runner', 'pytest-cov', 'vcrpy', 'pytest-vcr', 'pytest-mock'] | 3 | test_deps = ['pytest', 'pytest-runner', 'pytest-cov', 'vcrpy', 'pytest-vcr', 'pytest-mock', 'requests-mock'] |
4 | extras = { | 4 | extras = { |
5 | "test": test_deps | 5 | "test": test_deps |
6 | } | 6 | } |
diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..7329507 --- /dev/null +++ b/tests/test_errors.py | |||
@@ -0,0 +1,20 @@ | |||
1 | import pytest | ||
2 | from mastodon.Mastodon import MastodonAPIError | ||
3 | |||
4 | try: | ||
5 | from mock import MagicMock | ||
6 | except ImportError: | ||
7 | from unittest.mock import MagicMock | ||
8 | |||
9 | def test_nonstandard_errors(api): | ||
10 | response = MagicMock() | ||
11 | response.json = MagicMock(return_value= | ||
12 | "I am a non-standard instance and this error is a plain string.") | ||
13 | response.ok = False | ||
14 | session = MagicMock() | ||
15 | session.request = MagicMock(return_value=response) | ||
16 | |||
17 | api.session = session | ||
18 | with pytest.raises(MastodonAPIError): | ||
19 | api.instance() | ||
20 | |||
diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 599b2f4..d2c0bd5 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py | |||
@@ -1,5 +1,10 @@ | |||
1 | import pytest | 1 | import pytest |
2 | from contextlib import contextmanager | 2 | from contextlib import contextmanager |
3 | try: | ||
4 | from mock import MagicMock | ||
5 | except ImportError: | ||
6 | from unittest.mock import MagicMock | ||
7 | import requests_mock | ||
3 | 8 | ||
4 | UNLIKELY_HASHTAG = "fgiztsshwiaqqiztpmmjbtvmescsculuvmgjgopwoeidbcrixp" | 9 | UNLIKELY_HASHTAG = "fgiztsshwiaqqiztpmmjbtvmescsculuvmgjgopwoeidbcrixp" |
5 | 10 | ||
@@ -44,3 +49,18 @@ def test_fetch_remaining(api): | |||
44 | hashtag_remaining = api.fetch_remaining(hashtag) | 49 | hashtag_remaining = api.fetch_remaining(hashtag) |
45 | assert hashtag_remaining | 50 | assert hashtag_remaining |
46 | assert len(hashtag_remaining) >= 30 | 51 | assert len(hashtag_remaining) >= 30 |
52 | |||
53 | def test_link_headers(api): | ||
54 | rmock = requests_mock.Adapter() | ||
55 | api.session.mount(api.api_base_url, rmock) | ||
56 | |||
57 | _id='abc1234' | ||
58 | |||
59 | rmock.register_uri('GET', requests_mock.ANY, json=[{"foo": "bar"}], headers={"link":""" | ||
60 | <{base}/api/v1/timelines/tag/{tag}?max_id={_id}>; rel="next", <{base}/api/v1/timelines/tag/{tag}?since_id={_id}>; rel="prev" | ||
61 | """.format(base=api.api_base_url, tag=UNLIKELY_HASHTAG, _id=_id).strip() | ||
62 | }) | ||
63 | |||
64 | resp = api.timeline_hashtag(UNLIKELY_HASHTAG) | ||
65 | assert resp[0]._pagination_next['max_id'] == _id | ||
66 | assert resp[0]._pagination_prev['since_id'] == _id | ||