From 45908b6f4e9c66fe102b30764e6d153a8d0339a5 Mon Sep 17 00:00:00 2001 From: Lorenz Diener Date: Sat, 12 Oct 2019 22:55:17 +0200 Subject: [PATCH] Add, test and document last-read markers. Fixes #192 --- docs/index.rst | 35 ++++++++- mastodon/Mastodon.py | 50 ++++++++++++- tests/cassettes/test_markers.yaml | 117 ++++++++++++++++++++++++++++++ tests/test_markers.py | 17 +++++ 4 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 tests/cassettes/test_markers.yaml create mode 100644 tests/test_markers.py diff --git a/docs/index.rst b/docs/index.rst index ef94b2a..1e64927 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -773,6 +773,20 @@ Featured tag dicts # (can be None if there are none) } +Read marker dicts +~~~~~~~~~~~~~~~~~ +.. _read marker dict: + +.. code-block:: python + + mastodon.markers_get()["home"] + # Returns the following dictionary: + { + 'last_read_id': # ID of the last read object in the timeline + 'version': # A counter that is incremented whenever the marker is set to a new status + 'updated_at': # The time the marker was last set, as a datetime object + } + Admin account dicts ~~~~~~~~~~~~~~~~~~~ .. _admin account dict: @@ -990,11 +1004,20 @@ muted or blocked by the logged in user. .. automethod:: Mastodon.mutes .. automethod:: Mastodon.blocks -Reading data: Reports (REMOVED IN 2.5.0) ----------------------------------------- +Reading data: Reports +--------------------- +In Mastodon versions before 2.5.0 this function allowed for the retrieval +of reports filed by the logged in user. It has since been removed. .. automethod:: Mastodon.reports + +Writing data: Last-read markers +-------------------------- +This function allows you to set get last read position for timelines. + +.. automethod:: Mastodon.markers_get + Reading data: Domain blocks --------------------------- @@ -1142,6 +1165,14 @@ Writing data: Reports .. automethod:: Mastodon.report +Writing data: Last-read markers +-------------------------- +This function allows you to set the last read position for timelines to +allow for persisting where the user was reading a timeline between sessions +and clients / devices. + +.. automethod:: Mastodon.markers_set + Writing data: Domain blocks --------------------------- These functions allow you to block and unblock all statuses from a domain diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index d07bae7..b8ad976 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py @@ -213,6 +213,7 @@ class Mastodon: __DICT_VERSION_PREFERENCES = "2.8.0" __DICT_VERSION_ADMIN_ACCOUNT = "2.9.1" __DICT_VERSION_FEATURED_TAG = "3.0.0" + __DICT_VERSION_MARKER = "3.0.0" ### # Registering apps @@ -1590,6 +1591,25 @@ class Mastodon: """ return self.__api_request('GET', '/api/v1/preferences') + ## + # Reading data: Read markers + ## + @api_version("3.0.0", "3.0.0", __DICT_VERSION_MARKER) + def markers_get(self, timeline=["home"]): + """ + Get the last-read-location markers for the specified timelines. Valid timelines + are the same as in `timeline()`_ + + Note that despite the singular name, `timeline` can be a list. + + Returns a dict of `read marker dicts`_, keyed by timeline name. + """ + if not isinstance(timeline, (list, tuple)): + timeline = [timeline] + params = self.__generate_params(locals()) + + return self.__api_request('GET', '/api/v1/markers', params) + ### # Writing data: Statuses ### @@ -2450,6 +2470,34 @@ class Mastodon: params = self.__generate_params(locals()) self.__api_request('DELETE', '/api/v1/domain_blocks', params) + ## + # Writing data: Read markers + ## + @api_version("3.0.0", "3.0.0", __DICT_VERSION_MARKER) + def markers_set(self, timelines, last_read_ids): + """ + Set the "last read" marker(s) for the given timeline(s) to the given id(s) + + Note that if you give an invalid timeline name, this will silently do nothing. + + Returns a dict with the updated `read marker dicts`_, keyed by timeline name. + """ + if not isinstance(timelines, (list, tuple)): + timelines = [timelines] + + if not isinstance(last_read_ids, (list, tuple)): + last_read_ids = [last_read_ids] + + if len(last_read_ids) != len(timelines): + raise MastodonIllegalArgumentError("Number of specified timelines and ids must be the same") + + params = collections.OrderedDict() + for timeline, last_read_id in zip(timelines, last_read_ids): + params[timeline] = collections.OrderedDict() + params[timeline]["last_read_id"] = self.__unpack_id(last_read_id) + + return self.__api_request('POST', '/api/v1/markers', params, use_json=True) + ### # Writing data: Push subscriptions ### @@ -3071,7 +3119,7 @@ class Mastodon: """ Converts json string numerals to native python bignums. """ - for key in ('id', 'week', 'in_reply_to_id', 'in_reply_to_account_id', 'logins', 'registrations', 'statuses', 'day'): + for key in ('id', 'week', 'in_reply_to_id', 'in_reply_to_account_id', 'logins', 'registrations', 'statuses', 'day', 'last_read_id'): if (key in json_object and isinstance(json_object[key], six.text_type)): try: json_object[key] = int(json_object[key]) diff --git a/tests/cassettes/test_markers.yaml b/tests/cassettes/test_markers.yaml new file mode 100644 index 0000000..eceda6d --- /dev/null +++ b/tests/cassettes/test_markers.yaml @@ -0,0 +1,117 @@ +interactions: +- request: + body: status=Toot%21 + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + Content-Length: ['14'] + Content-Type: [application/x-www-form-urlencoded] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/api/v1/statuses + response: + body: {string: '{"id":"102951400589982753","created_at":"2019-10-12T20:55:05.296Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"ja","uri":"http://localhost/users/mastodonpy_test/statuses/102951400589982753","url":"http://localhost/@mastodonpy_test/102951400589982753","replies_count":0,"reblogs_count":0,"favourites_count":0,"favourited":false,"reblogged":false,"muted":false,"pinned":false,"content":"\u003cp\u003eToot!\u003c/p\u003e","reblog":null,"application":{"name":"Mastodon.py + test suite","website":null},"account":{"id":"1234567890123456","username":"mastodonpy_test","acct":"mastodonpy_test","display_name":"","locked":false,"bot":false,"created_at":"2019-06-22T23:11:52.441Z","note":"\u003cp\u003e\u003c/p\u003e","url":"http://localhost/@mastodonpy_test","avatar":"http://localhost/avatars/original/missing.png","avatar_static":"http://localhost/avatars/original/missing.png","header":"http://localhost/headers/original/missing.png","header_static":"http://localhost/headers/original/missing.png","followers_count":0,"following_count":1,"statuses_count":3,"last_status_at":"2019-10-12T20:55:05.311Z","emojis":[],"fields":[]},"media_attachments":[],"mentions":[],"tags":[],"emojis":[],"card":null,"poll":null}'} + headers: + Cache-Control: ['no-cache, no-store'] + Content-Type: [application/json; charset=utf-8] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [d6e097f3-e4e8-4d83-8c30-c4fe5ed88a87] + X-Runtime: ['0.151706'] + X-XSS-Protection: [1; mode=block] + content-length: ['1280'] + status: {code: 200, message: OK} +- request: + body: '{"home": {"last_read_id": 102951400589982753}}' + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + Content-Length: ['46'] + Content-Type: [application/json] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/api/v1/markers + response: + body: {string: '{"home":{"last_read_id":"102951400589982753","version":2,"updated_at":"2019-10-12T20:55:05.457Z"}}'} + headers: + Cache-Control: ['no-cache, no-store'] + Content-Type: [application/json; charset=utf-8] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [2e3c3ede-edf6-43c9-9477-fddca87ffed3] + X-Runtime: ['0.036295'] + X-XSS-Protection: [1; mode=block] + content-length: ['98'] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + User-Agent: [python-requests/2.18.4] + method: GET + uri: http://localhost:3000/api/v1/markers?timeline%5B%5D=home + response: + body: {string: '{"home":{"last_read_id":"102951400589982753","version":2,"updated_at":"2019-10-12T20:55:05.457Z"}}'} + headers: + Cache-Control: ['no-cache, no-store'] + Content-Type: [application/json; charset=utf-8] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [2d3ddb8a-b93c-48e2-bdda-c4e0edf29adf] + X-Runtime: ['0.022064'] + X-XSS-Protection: [1; mode=block] + content-length: ['98'] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer __MASTODON_PY_TEST_ACCESS_TOKEN] + Connection: [keep-alive] + Content-Length: ['0'] + User-Agent: [python-requests/2.18.4] + method: DELETE + uri: http://localhost:3000/api/v1/statuses/102951400589982753 + response: + body: {string: '{"id":"102951400589982753","created_at":"2019-10-12T20:55:05.296Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"ja","uri":"http://localhost/users/mastodonpy_test/statuses/102951400589982753","url":"http://localhost/@mastodonpy_test/102951400589982753","replies_count":0,"reblogs_count":0,"favourites_count":0,"favourited":false,"reblogged":false,"muted":false,"pinned":false,"text":"Toot!","reblog":null,"application":{"name":"Mastodon.py + test suite","website":null},"account":{"id":"1234567890123456","username":"mastodonpy_test","acct":"mastodonpy_test","display_name":"","locked":false,"bot":false,"created_at":"2019-06-22T23:11:52.441Z","note":"\u003cp\u003e\u003c/p\u003e","url":"http://localhost/@mastodonpy_test","avatar":"http://localhost/avatars/original/missing.png","avatar_static":"http://localhost/avatars/original/missing.png","header":"http://localhost/headers/original/missing.png","header_static":"http://localhost/headers/original/missing.png","followers_count":0,"following_count":1,"statuses_count":3,"last_status_at":"2019-10-12T20:55:05.311Z","emojis":[],"fields":[]},"media_attachments":[],"mentions":[],"tags":[],"emojis":[],"card":null,"poll":null}'} + headers: + Cache-Control: ['no-cache, no-store'] + Content-Type: [application/json; charset=utf-8] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [526d76d7-5428-45d2-9a84-4f520b09524c] + X-Runtime: ['0.121781'] + X-XSS-Protection: [1; mode=block] + content-length: ['1250'] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/test_markers.py b/tests/test_markers.py new file mode 100644 index 0000000..a35c56c --- /dev/null +++ b/tests/test_markers.py @@ -0,0 +1,17 @@ +import pytest + +@pytest.mark.vcr() +def test_markers(api, status): + marker_a = api.markers_set("home", status) + assert marker_a + assert marker_a["home"] + + marker_b = api.markers_get("home") + assert marker_b + assert marker_b["home"] + + assert marker_a.home.version == marker_b.home.version + assert marker_a.home.last_read_id == status.id + assert marker_b.home.last_read_id == status.id + +