From 0d0b71b79dcaa9f94a23d54eb58ac3a96466498c Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Sat, 27 Jun 2020 17:06:51 +0100 Subject: [PATCH 01/10] Increment version --- scuttle/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scuttle/__init__.py b/scuttle/__init__.py index a57aca5..0631766 100644 --- a/scuttle/__init__.py +++ b/scuttle/__init__.py @@ -1,3 +1,3 @@ from .wrapper import scuttle -__version__ = "0.1.0" +__version__ = "0.2.0" From 48e3407cbeb33abfcaa93b266fa4f8d1f6e08eb3 Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Sat, 27 Jun 2020 17:11:46 +0100 Subject: [PATCH 02/10] Add tests for pagination --- test/test_api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/test_api.py b/test/test_api.py index cb2629c..4095bc0 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -29,6 +29,19 @@ def test_wiki(): assert wiki.wikis()[0]['subdomain'] == "admin" assert wiki.wiki()['subdomain'] == "en" +def test_pagination(): + wiki = scuttle.scuttle('en', API_KEY, 1) + # will be testing on page revisions pagination + page_slug = "main" + page_id = wiki.page_by_slug(page_slug)['id'] + # non-paginated revisions - should just be metadata + non_paginated_revisions = wiki.all_pagerevisions(page_id) + assert len(non_paginated_revisions) > 100 + assert 'content' not in non_paginated_revisions[0].keys() + # paginated revisions - should include revision content + paginated_revisions = wiki.all_pagerevisions.verbose(page_id) + assert len(paginated_revisions) == 20 + def test_page(): wiki = scuttle.scuttle('en', API_KEY, 1) pages = wiki.all_pages() From bd3ca6ec224ee5c7b729e839ac3a22edb564779d Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Tue, 30 Jun 2020 09:06:32 +0100 Subject: [PATCH 03/10] Add paginated methods --- .gitignore | 1 - Pipfile | 1 + scuttle/versions/base.py | 2 +- scuttle/versions/v1.py | 329 +++++++++++++++++++++------------------ test/test_api.py | 5 + 5 files changed, 181 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index aac816a..626cce7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ __pycache__ env .venv .vscode -pylintrc .cache Pipfile.lock *.egg-info diff --git a/Pipfile b/Pipfile index 2b6eee5..9c4b8b4 100644 --- a/Pipfile +++ b/Pipfile @@ -11,6 +11,7 @@ python-dateutil = "*" [packages] requests = "*" +wrapt = "*" [requires] python_version = "3.8" diff --git a/scuttle/versions/base.py b/scuttle/versions/base.py index 6d321cf..06877b0 100644 --- a/scuttle/versions/base.py +++ b/scuttle/versions/base.py @@ -9,7 +9,7 @@ def __init__(self, domain, api_key): domain, type(self).version) self.api_key = api_key - def _request(self, namespace, value=None, data=None): + def request(self, namespace, value=None, data=None): method = 'get' if data is None else 'post' send = {'headers': {"Authorization": "Bearer {}".format(self.api_key)}} if data is not None: diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index 32dc6bc..3062d4f 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -3,201 +3,228 @@ """Provides generic methods for accessing version 1 of the API.""" from collections.abc import Iterable +from typing import Callable, List, Union + +import wrapt from .base import BaseApi +class NoNonPaginatedVersionError(Exception): + """Raised when a non-paginated of a paginated-only method is called.""" + pass + +@wrapt.decorator +def has_paginated_version(method, instance, args, kwargs): + def paginated(*args, limit=None, offset=None, direction=None): + print("PAGINATED METHOD", method) + print("PAGINATED ARGS", args) + data = { + 'limit': 20 if limit is None else limit, + 'offset': 0 if offset is None else offset, + 'direction': 'asc' if direction is None else direction, + } + return method(*args, data=data) + setattr(instance.verbose, method.__name__, paginated) + return method + +def endpoint(endpoint_url): + @wrapt.decorator + def wrapper(method, instance, args, kwargs): + return instance.request(endpoint_url, *method(*args, **kwargs)) + return wrapper + +class PaginatedMethod: + def __init__(self, method: Callable, verbose_only: bool = False): + self._method = method + self._verbose_only = verbose_only + + def __call__(self, *args, **kwargs): + # Check if this method is only paginated + # Pop the kwarg first as it must not reach the original method + if not kwargs.pop('__verbose', False) and self._verbose_only: + raise NoNonPaginatedVersionError( + "{} does not have a non-paginated version - use {}.verbose() instead" + ) + return self._method(*args, **kwargs) + + def verbose(self, *args, limit=None, offset=None, direction=None): + data = { + 'limit': 20 if limit is None else limit, + 'offset': 0 if offset is None else offset, + 'direction': 'asc' if direction is None else direction, + } + return self.__call__(*args, data=data, __verbose=True) + class Api(BaseApi): """API version 1""" version = 1 + def __init__(self, *args): + super().__init__(*args) + self.all_pages_since = PaginatedMethod(self._all_pages_since) + self.all_pagerevisions = PaginatedMethod(self._all_pagerevisions) + self.forum_threads_since = PaginatedMethod(self._forum_threads_since, + True) + self.thread_posts = PaginatedMethod(self._thread_posts) + self.thread_posts_since = PaginatedMethod(self._thread_posts_since, + True) + self.wikidotuser_pages = PaginatedMethod(self._wikidotuser_pages) + self.wikidotuser_posts = PaginatedMethod(self._wikidotuser_posts) + self.wikidotuser_revisions = PaginatedMethod(self._wikidotuser_revisions) + self.tags_pages = PaginatedMethod(self._tags_pages, True) + + @endpoint("wikis") def wikis(self): - return self._request("wikis") + return None, None + @endpoint("wiki") def wiki(self): - return self._request("wiki") + return None, None + @endpoint("page") def all_pages(self): - return self._request("page") + return None, None - def all_pages_since(self, since, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } + @endpoint("page/since/{}") + def _all_pages_since(self, since: int, *, data=None): if not isinstance(since, int): raise TypeError("`since` must be a UNIX timestamp") - return self._request("page/since/{}", since, data) + return since, data - def page_by_id(self, page_id): - return self._request("page/{}", page_id) + @endpoint("page/{}") + def page_by_id(self, page_id: int): + return page_id, None - def page_by_slug(self, page_slug): - return self._request("page/slug/{}", page_slug) + @endpoint("page/slug/{}") + def page_by_slug(self, page_slug: str): + return page_slug, None - def all_page_revisions(self, page_id): - return self._request("page/{}/revisions", page_id) - - def page_revisions(self, page_id, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - return self._request("page/{}/revisions", page_id, data) + @endpoint("page/{}/revisions") + def _all_pagerevisions(self, page_id: int, *, data=None): + return page_id, data + @endpoint("page/{}/votes") def page_votes(self, page_id): - return self._request("page/{}/votes", page_id) + return page_id, None + @endpoint("page/{}/tags") def page_tags(self, page_id): - return self._request("page/{}/tags", page_id) + return page_id, None + @endpoint("page/{}/files") def page_files(self, page_id): - return self._request("page/{}/files", page_id) + return page_id, None - def get_revision(self, revision_id): - return self._request("revision/{}", revision_id) + @endpoint("revision/{}") + def getrevision(self, revision_id): + return revision_id, None - def get_full_revision(self, revision_id): - return self._request("revision/{}/full", revision_id) + @endpoint("revision/{}/full") + def get_fullrevision(self, revision_id): + return revision_id, None + @endpoint("forum") def all_forums(self): - return self._request("forum") + return None, None + @endpoint("forum/{}") def forum(self, forum_id): - return self._request("forum/{}", forum_id) + return forum_id, None + @endpoint("forum/{}/threads") def forum_threads(self, forum_id): - return self._request("forum/{}/threads", forum_id) + return forum_id, None - def forum_threads_since(self, forum_id, since, *, - limit=None, offset=None, direction=None): - data = { - 'timestamp': since, - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } + @endpoint("forum/{}/since") + def _forum_threads_since(self, forum_id, since, *, data): if not isinstance(since, int): raise TypeError("`since` must be a UNIX timestamp") - return self._request("forum/{}/since", forum_id, data) + data['timestamp'] = since + return forum_id, data + @endpoint("thread/{}") def thread(self, thread_id): - return self._request("thread/{}", thread_id) + return thread_id, None + @endpoint("thread/{}/posts") def all_thread_posts(self, thread_id): - return self._request("thread/{}/posts", thread_id) + return thread_id, None - def thread_posts(self, thread_id, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - return self._request("thread/{}/posts", thread_id, data) + @endpoint("thread/{}/posts") + def _thread_posts(self, thread_id, *, data=None): + return thread_id, data - def thread_posts_since(self, thread_id, since, *, - limit=None, offset=None, direction=None): - data = { - 'timestamp': since, - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } + @endpoint("thread/{}/since") + def _thread_posts_since(self, thread_id, since, *, data): if not isinstance(since, int): raise TypeError("`since` must be a UNIX timestamp") - return self._request("thread/{}/since", thread_id, data) + return thread_id, data + @endpoint("post/{}") def post(self, post_id): - return self._request("post/{}", post_id) + return post_id, None + @endpoint("post/{}/children") def post_children(self, post_id): - return self._request("post/{}/children", post_id) + return post_id, None + @endpoint("post/{}/parent") def post_parent(self, post_id): - return self._request("post/{}/parent", post_id) - - def wikidotuser(self, wikidotuser_id): - if isinstance(wikidotuser_id, int): - return self._request("wikidotuser/{}", wikidotuser_id) - return self._request("wikidotuser/username/{}", wikidotuser_id) - - def wikidotuser_avatar(self, wikidotuser_id): - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/avatar", wikidotuser_id) - - def all_wikidotuser_pages(self, wikidotuser_id): - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/pages", wikidotuser_id) - - def wikidotuser_pages(self, wikidotuser_id, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/pages", wikidotuser_id, data) - - def all_wikidotuser_posts(self, wikidotuser_id): - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/posts", wikidotuser_id) - - def wikidotuser_posts(self, wikidotuser_id, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/posts", wikidotuser_id, data) - - def all_wikidotuser_revisions(self, wikidotuser_id): - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/revisions", wikidotuser_id) - - def wikidotuser_revisions(self, wikidotuser_id, *, - limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/revisions", wikidotuser_id, data) - - def wikidotuser_votes(self, wikidotuser_id): - if not isinstance(wikidotuser_id, int): - raise TypeError("The Wikidot user ID must be an int") - return self._request("wikidotuser/{}/votes", wikidotuser_id) - + return post_id, None + + @endpoint("wikidotuser/{}") + def wikidotuser(self, user_id: int): + return user_id, None + + @endpoint("wikidotuser/username/{}") + def wikidotuser_name(self, wikidot_username: str): + return wikidot_username, None + + @endpoint("wikidotuser/{}/avatar") + def wikidotuser_avatar(self, wikidot_user_id: int): + return wikidot_user_id, None + + @endpoint("wikidotuser/{}/pages") + def _wikidotuser_pages(self, wikidot_user_id: int, *, data=None): + if not isinstance(wikidot_user_id, int): + raise TypeError("Wikidot user ID must be an int") + return wikidot_user_id, data + + @endpoint("wikidotuser/{}/posts") + def _wikidotuser_posts(self, wikidot_user_id: int, *, data=None): + if not isinstance(wikidot_user_id, int): + raise TypeError("Wikidot user ID must be an int") + return wikidot_user_id, data + + @endpoint("wikidotuser/{}/revisions") + def _wikidotuser_revisions(self, wikidot_user_id: int, *, data=None): + if not isinstance(wikidot_user_id, int): + raise TypeError("Wikidot user ID must be an int") + return wikidot_user_id, data + + @endpoint("wikidotuser/{}/votes") + def wikidotuser_votes(self, wikidot_user_id: int): + if not isinstance(wikidot_user_id, int): + raise TypeError("Wikidot user ID must be an int") + return wikidot_user_id, None + + @endpoint("tag") def tags(self): - return self._request("tag") + return None, None - def tag_pages(self, tags): + @endpoint("tag/{}/pages") + def tag_pages(self, tag: str): """ - str `tags`: One tag, finds page IDs with that tag. + str `tag`: One tag, finds page IDs with that tag. """ - if not isinstance(tags, str): + if not isinstance(tag, str): raise TypeError("A single tag must be a string") - return self._request("tag/{}/pages", tags) + return tag, None - def tags_pages(self, tags, operator='and', *, - limit=None, offset=None, direction=None): + @endpoint("tag/pages") + def _tags_pages(self, tags: Union[List[int], List[str]], operator: str = 'and', *, data): """ - str[] `tags`: A list of tags, finds all page IDs that match the + str[] `tags`: A list of tag names, finds all page IDs that match the condition. int[] `tags`: A list of SCUTTLE tag IDs, finds all page IDs that match the condition. @@ -205,20 +232,12 @@ def tags_pages(self, tags, operator='and', *, multiple tags. """ if isinstance(tags, str): - raise TypeError("tags must be str[] or int[]; use tag_pages()" - "for single tags") - if not isinstance(tags, Iterable): - raise TypeError("tags must be a list of str or int") - data = { - 'operator': operator, - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } + raise TypeError("`tags` must be a list of at least one tag; use tag_pages() for a single tag name") + data['operator'] = operator if all(isinstance(tag, str) for tag in tags): - data.update({'names': tags}) - return self._request("tag/pages", None, data) - if all(isinstance(tag, int) for tag in tags): - data.update({'ids': tags}) - return self._request("tag/pages", None, data) - raise TypeError("tags must be a list of str or int") + data['names'] = tags + elif all(isinstance(tag, int) for tag in tags): + data['ids'] = tags + else: + raise TypeError("`tags` must be a list of str or int") + return self.request("tag/pages", None, data) diff --git a/test/test_api.py b/test/test_api.py index 4095bc0..9b84374 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -35,11 +35,16 @@ def test_pagination(): page_slug = "main" page_id = wiki.page_by_slug(page_slug)['id'] # non-paginated revisions - should just be metadata + print("DOING non-paginated") non_paginated_revisions = wiki.all_pagerevisions(page_id) + print("DONE non-paginated") assert len(non_paginated_revisions) > 100 assert 'content' not in non_paginated_revisions[0].keys() # paginated revisions - should include revision content + print("DOING paginated") paginated_revisions = wiki.all_pagerevisions.verbose(page_id) + print("DONE paginated") + assert 'content' in paginated_revisions[0].keys() assert len(paginated_revisions) == 20 def test_page(): From 8859ce7ca2dd84a0e2ac9879f683e6ee8f4f842d Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Tue, 30 Jun 2020 09:09:21 +0100 Subject: [PATCH 04/10] Remove 'all_' from methods that used to be unpaginated --- scuttle/versions/v1.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index 3062d4f..2979c92 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -61,8 +61,8 @@ class Api(BaseApi): def __init__(self, *args): super().__init__(*args) - self.all_pages_since = PaginatedMethod(self._all_pages_since) - self.all_pagerevisions = PaginatedMethod(self._all_pagerevisions) + self.pages_since = PaginatedMethod(self._pages_since) + self.pagerevisions = PaginatedMethod(self._pagerevisions) self.forum_threads_since = PaginatedMethod(self._forum_threads_since, True) self.thread_posts = PaginatedMethod(self._thread_posts) @@ -82,11 +82,11 @@ def wiki(self): return None, None @endpoint("page") - def all_pages(self): + def pages(self): return None, None @endpoint("page/since/{}") - def _all_pages_since(self, since: int, *, data=None): + def _pages_since(self, since: int, *, data=None): if not isinstance(since, int): raise TypeError("`since` must be a UNIX timestamp") return since, data @@ -100,7 +100,7 @@ def page_by_slug(self, page_slug: str): return page_slug, None @endpoint("page/{}/revisions") - def _all_pagerevisions(self, page_id: int, *, data=None): + def _pagerevisions(self, page_id: int, *, data=None): return page_id, data @endpoint("page/{}/votes") @@ -124,7 +124,7 @@ def get_fullrevision(self, revision_id): return revision_id, None @endpoint("forum") - def all_forums(self): + def forums(self): return None, None @endpoint("forum/{}") @@ -146,10 +146,6 @@ def _forum_threads_since(self, forum_id, since, *, data): def thread(self, thread_id): return thread_id, None - @endpoint("thread/{}/posts") - def all_thread_posts(self, thread_id): - return thread_id, None - @endpoint("thread/{}/posts") def _thread_posts(self, thread_id, *, data=None): return thread_id, data From ffcc11ecc308aa188a38f93d9417b2d1bf2eb861 Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Tue, 30 Jun 2020 09:34:24 +0100 Subject: [PATCH 05/10] Standardise names --- scuttle/versions/v1.py | 9 ++++----- test/__init__.py | 0 test/test_api.py | 22 +++++++++++----------- 3 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 test/__init__.py diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index 2979c92..538e435 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -2,7 +2,6 @@ """Provides generic methods for accessing version 1 of the API.""" -from collections.abc import Iterable from typing import Callable, List, Union import wrapt @@ -62,7 +61,7 @@ class Api(BaseApi): def __init__(self, *args): super().__init__(*args) self.pages_since = PaginatedMethod(self._pages_since) - self.pagerevisions = PaginatedMethod(self._pagerevisions) + self.page_revisions = PaginatedMethod(self._page_revisions) self.forum_threads_since = PaginatedMethod(self._forum_threads_since, True) self.thread_posts = PaginatedMethod(self._thread_posts) @@ -100,7 +99,7 @@ def page_by_slug(self, page_slug: str): return page_slug, None @endpoint("page/{}/revisions") - def _pagerevisions(self, page_id: int, *, data=None): + def _page_revisions(self, page_id: int, *, data=None): return page_id, data @endpoint("page/{}/votes") @@ -116,11 +115,11 @@ def page_files(self, page_id): return page_id, None @endpoint("revision/{}") - def getrevision(self, revision_id): + def revision(self, revision_id): return revision_id, None @endpoint("revision/{}/full") - def get_fullrevision(self, revision_id): + def full_revision(self, revision_id): return revision_id, None @endpoint("forum") diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_api.py b/test/test_api.py index 9b84374..3d86447 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -15,9 +15,9 @@ def test_basic(): assert isinstance(wiki.api, scuttle.versions.v1.Api) def test_get_nonexistent_version(): - with pytest.raises(ModuleNotFoundError) as e: + with pytest.raises(ModuleNotFoundError) as error: scuttle.scuttle('en', None, 0) - assert str(e.value) == "API version 0 does not exist." + assert str(error.value) == "API version 0 does not exist." def test_get_default_version(): wiki = scuttle.scuttle('en', None) @@ -36,20 +36,20 @@ def test_pagination(): page_id = wiki.page_by_slug(page_slug)['id'] # non-paginated revisions - should just be metadata print("DOING non-paginated") - non_paginated_revisions = wiki.all_pagerevisions(page_id) + non_paginated_revisions = wiki.pagerevisions(page_id) print("DONE non-paginated") assert len(non_paginated_revisions) > 100 assert 'content' not in non_paginated_revisions[0].keys() # paginated revisions - should include revision content print("DOING paginated") - paginated_revisions = wiki.all_pagerevisions.verbose(page_id) + paginated_revisions = wiki.pagerevisions.verbose(page_id) print("DONE paginated") assert 'content' in paginated_revisions[0].keys() assert len(paginated_revisions) == 20 def test_page(): wiki = scuttle.scuttle('en', API_KEY, 1) - pages = wiki.all_pages() + pages = wiki.pages() assert set(pages[0].keys()) == {'id', 'slug', 'wd_page_id'} page_id = pages[0]['id'] assert wiki.page_by_id(page_id)['id'] == page_id @@ -62,19 +62,19 @@ def test_page(): if len(files := wiki.page_files(page_id)) > 0: assert isinstance(files[0]['path'], str) timestamp = 1500000000 - pages_since_then = wiki.all_pages_since_mini(timestamp) + pages_since_then = wiki.pages_since(timestamp) print(pages_since_then) assert all(page['metadata']['wd_page_created_at'] >= timestamp for page in pages_since_then) def test_revisions(): wiki = scuttle.scuttle('en', API_KEY, 1) - page_id = wiki.all_pages()[0]['id'] + page_id = wiki.pages()[0]['id'] print(f"{page_id=}") - revision_id = wiki.all_page_revisions(page_id)[0]['id'] + revision_id = wiki.page_revisions(page_id)[0]['id'] print(f"{revision_id=}") - assert wiki.get_revision(revision_id)['page_id'] == page_id - full_revision = wiki.get_full_revision(revision_id) + assert wiki.revision(revision_id)['page_id'] == page_id + full_revision = wiki.full_revision(revision_id) print(f"{full_revision=}") assert full_revision['page_id'] == page_id assert 'content' in full_revision @@ -87,7 +87,7 @@ def test_revisions(): def test_forums(): wiki = scuttle.scuttle('en', API_KEY, 1) - forum_id = wiki.all_forums()[0]['id'] + forum_id = wiki.forums()[0]['id'] assert wiki.forum(forum_id)['id'] == forum_id def test_tags(): From 368da51072076712b69b41685e997138ba69b18a Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Tue, 30 Jun 2020 09:41:11 +0100 Subject: [PATCH 06/10] Tests pass! --- test/test_api.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/test_api.py b/test/test_api.py index 3d86447..c9746b9 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -36,13 +36,13 @@ def test_pagination(): page_id = wiki.page_by_slug(page_slug)['id'] # non-paginated revisions - should just be metadata print("DOING non-paginated") - non_paginated_revisions = wiki.pagerevisions(page_id) + non_paginated_revisions = wiki.page_revisions(page_id) print("DONE non-paginated") assert len(non_paginated_revisions) > 100 assert 'content' not in non_paginated_revisions[0].keys() # paginated revisions - should include revision content print("DOING paginated") - paginated_revisions = wiki.pagerevisions.verbose(page_id) + paginated_revisions = wiki.page_revisions.verbose(page_id) print("DONE paginated") assert 'content' in paginated_revisions[0].keys() assert len(paginated_revisions) == 20 @@ -61,11 +61,12 @@ def test_page(): assert isinstance(tags[0]['name'], str) if len(files := wiki.page_files(page_id)) > 0: assert isinstance(files[0]['path'], str) - timestamp = 1500000000 - pages_since_then = wiki.pages_since(timestamp) - print(pages_since_then) - assert all(page['metadata']['wd_page_created_at'] >= timestamp - for page in pages_since_then) + # XXX waiting on propagation + # timestamp = 1500000000 + # pages_since_then = wiki.pages_since(timestamp) + # print(pages_since_then) + # assert all(page['metadata']['wd_page_created_at'] >= timestamp + # for page in pages_since_then) def test_revisions(): wiki = scuttle.scuttle('en', API_KEY, 1) @@ -78,10 +79,10 @@ def test_revisions(): print(f"{full_revision=}") assert full_revision['page_id'] == page_id assert 'content' in full_revision - first_rev = wiki.page_revisions(page_id, limit=1, direction="asc") + first_rev = wiki.page_revisions.verbose(page_id, limit=1, direction='asc') print(f"{first_rev=}") assert len(first_rev) == 1 - final_rev = wiki.page_revisions(page_id, limit=1, direction="desc") + final_rev = wiki.page_revisions.verbose(page_id, limit=1, direction='desc') print(f"{final_rev=}") assert first_rev[0]['metadata']['wikidot_metadata']['timestamp'] <= final_rev[0]['metadata']['wikidot_metadata']['timestamp'] From e6e9bba315f9d920b8f94fd115b82522d851946d Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Tue, 30 Jun 2020 09:47:40 +0100 Subject: [PATCH 07/10] Add a little documentation --- scuttle/versions/v1.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index 538e435..ab86a05 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -10,29 +10,21 @@ class NoNonPaginatedVersionError(Exception): """Raised when a non-paginated of a paginated-only method is called.""" - pass - -@wrapt.decorator -def has_paginated_version(method, instance, args, kwargs): - def paginated(*args, limit=None, offset=None, direction=None): - print("PAGINATED METHOD", method) - print("PAGINATED ARGS", args) - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } - return method(*args, data=data) - setattr(instance.verbose, method.__name__, paginated) - return method def endpoint(endpoint_url): + """Decorator for API methods. Denotes the URL endpoint.""" @wrapt.decorator def wrapper(method, instance, args, kwargs): return instance.request(endpoint_url, *method(*args, **kwargs)) return wrapper class PaginatedMethod: + """Object representing a method that has a POST paginated version (with + args like limit, offset, direction) as well as optionally a GET + non-paginated version. The GET version is accessed by calling the object as + if it were a method. The POST version is accessed by calling the `verbose` + attribute. + """ def __init__(self, method: Callable, verbose_only: bool = False): self._method = method self._verbose_only = verbose_only @@ -219,12 +211,9 @@ def tag_pages(self, tag: str): @endpoint("tag/pages") def _tags_pages(self, tags: Union[List[int], List[str]], operator: str = 'and', *, data): """ - str[] `tags`: A list of tag names, finds all page IDs that match the - condition. - int[] `tags`: A list of SCUTTLE tag IDs, finds all page IDs that match - the condition. - str `operator`: 'and' or 'or'; defines the condition when specifying - multiple tags. + str[] `tags`: A list of tag names. + int[] `tags`: A list of SCUTTLE tag IDs. + str `operator`: 'and' or 'or'; defines how tags are combined. """ if isinstance(tags, str): raise TypeError("`tags` must be a list of at least one tag; use tag_pages() for a single tag name") From d628d86123b9a2d2966a95e33c7c90a1671dcfe3 Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Wed, 1 Jul 2020 01:00:38 +0100 Subject: [PATCH 08/10] Add a generator to iterate through paginated methods --- scuttle/versions/v1.py | 64 ++++++++++++++++++++++++++++++++++++++---- test/test_api.py | 21 +++++++++++--- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index ab86a05..737abc9 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -18,6 +18,19 @@ def wrapper(method, instance, args, kwargs): return instance.request(endpoint_url, *method(*args, **kwargs)) return wrapper +def get_default_data(**kwargs): + """Returns a POST data dict using default values.""" + limit = kwargs.get('limit', 20) + offset = kwargs.get('offset', 0) + direction = kwargs.get('direction', 'asc') + if not isinstance(limit, int): + raise TypeError("`limit` must be int") + if not isinstance(offset, int): + raise TypeError("`offset` must be int") + if not direction in ('asc', 'desc'): + raise ValueError("`direction` must be one of 'asc', 'desc'") + return { 'limit': limit, 'offset': offset, 'direction': direction } + class PaginatedMethod: """Object representing a method that has a POST paginated version (with args like limit, offset, direction) as well as optionally a GET @@ -38,12 +51,8 @@ def __call__(self, *args, **kwargs): ) return self._method(*args, **kwargs) - def verbose(self, *args, limit=None, offset=None, direction=None): - data = { - 'limit': 20 if limit is None else limit, - 'offset': 0 if offset is None else offset, - 'direction': 'asc' if direction is None else direction, - } + def verbose(self, *args, **kwargs): + data = get_default_data(**kwargs) return self.__call__(*args, data=data, __verbose=True) class Api(BaseApi): @@ -64,6 +73,49 @@ def __init__(self, *args): self.wikidotuser_revisions = PaginatedMethod(self._wikidotuser_revisions) self.tags_pages = PaginatedMethod(self._tags_pages, True) + def verbose(self, method: Callable, *args, **kwargs): + """Returns a generator that iterates over a paginated method. + + callable `method`: The paginated method to iterate. + Remaining arguments will be passed to this method. + + + Pass this function int `limit`, an initial int `offset`, and str + `direction`. Each time the returned generator is called, it will + increment `offset` by `limit` and return the method for the resulting + set of parameters. Effectively, applied to a paginated method, this + generator is the same as turning the page. + + wiki = scuttle(domain, API_KEY, 1) + generator = wiki.verbose(wiki.thread_posts, thread_id, limit=5) + for posts in generator: + print(len(posts)) # will be 5, except at very end + + Note that at the end of the data, the length of the final 'page' will + very likely be less than `limit`. + """ + print("hello!") + print(method, isinstance(method, PaginatedMethod)) + if not isinstance(method, PaginatedMethod): + raise TypeError("Iterated method must be a paginated method") + data = get_default_data(**kwargs) + + def paginated_generator(): + limit: int = data['limit'] + length: int = limit + offset: int = data['offset'] + direction: str = data['direction'] + while True: + result = method.verbose(*args, limit=limit, offset=offset, + direction=direction) + yield result + if length < data['limit']: + return + length = len(result) + offset += length + + return paginated_generator() + @endpoint("wikis") def wikis(self): return None, None diff --git a/test/test_api.py b/test/test_api.py index c9746b9..d42aaa1 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -35,18 +35,31 @@ def test_pagination(): page_slug = "main" page_id = wiki.page_by_slug(page_slug)['id'] # non-paginated revisions - should just be metadata - print("DOING non-paginated") non_paginated_revisions = wiki.page_revisions(page_id) - print("DONE non-paginated") assert len(non_paginated_revisions) > 100 assert 'content' not in non_paginated_revisions[0].keys() # paginated revisions - should include revision content - print("DOING paginated") paginated_revisions = wiki.page_revisions.verbose(page_id) - print("DONE paginated") assert 'content' in paginated_revisions[0].keys() assert len(paginated_revisions) == 20 +def test_pagination_generator(): + wiki = scuttle.scuttle('en', API_KEY, 1) + # make a generator + page_slug = "main" + page_id = wiki.page_by_slug(page_slug)['id'] + gen1 = wiki.verbose(wiki.page_revisions, page_id, limit=100) + assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 0 + assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 100 + # make another generator, see if they interfere + gen2 = wiki.verbose(wiki.page_revisions, page_id, limit=10, offset=10) + assert int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) == 10 + assert int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) == 20 + assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 200 + # check errors + with pytest.raises(TypeError): + wiki.verbose(len) + def test_page(): wiki = scuttle.scuttle('en', API_KEY, 1) pages = wiki.pages() From f02067892ce17b591fea1b2b0d024c5ee70e5c5f Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Wed, 1 Jul 2020 06:03:49 +0100 Subject: [PATCH 09/10] Blacken --- scuttle/versions/v1.py | 37 +++++++++++++++++++++++++++---------- test/test_api.py | 29 ++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/scuttle/versions/v1.py b/scuttle/versions/v1.py index fc8164a..c79deb8 100644 --- a/scuttle/versions/v1.py +++ b/scuttle/versions/v1.py @@ -8,16 +8,21 @@ from .base import BaseApi + class NoNonPaginatedVersionError(Exception): """Raised when a non-paginated of a paginated-only method is called.""" + def endpoint(endpoint_url): """Decorator for API methods. Denotes the URL endpoint.""" + @wrapt.decorator def wrapper(method, instance, args, kwargs): return instance.request(endpoint_url, *method(*args, **kwargs)) + return wrapper + def get_default_data(**kwargs): """Returns a POST data dict using default values.""" limit = kwargs.get('limit', 20) @@ -29,7 +34,8 @@ def get_default_data(**kwargs): raise TypeError("`offset` must be int") if not direction in ('asc', 'desc'): raise ValueError("`direction` must be one of 'asc', 'desc'") - return { 'limit': limit, 'offset': offset, 'direction': direction } + return {'limit': limit, 'offset': offset, 'direction': direction} + class PaginatedMethod: """Object representing a method that has a POST paginated version (with @@ -38,6 +44,7 @@ class PaginatedMethod: if it were a method. The POST version is accessed by calling the `verbose` attribute. """ + def __init__(self, method: Callable, verbose_only: bool = False): self._method = method self._verbose_only = verbose_only @@ -55,6 +62,7 @@ def verbose(self, *args, **kwargs): data = get_default_data(**kwargs) return self.__call__(*args, data=data, __verbose=True) + class Api(BaseApi): """API version 1""" @@ -64,14 +72,18 @@ def __init__(self, *args): super().__init__(*args) self.pages_since = PaginatedMethod(self._pages_since) self.page_revisions = PaginatedMethod(self._page_revisions) - self.forum_threads_since = PaginatedMethod(self._forum_threads_since, - True) + self.forum_threads_since = PaginatedMethod( + self._forum_threads_since, True + ) self.thread_posts = PaginatedMethod(self._thread_posts) - self.thread_posts_since = PaginatedMethod(self._thread_posts_since, - True) + self.thread_posts_since = PaginatedMethod( + self._thread_posts_since, True + ) self.wikidotuser_pages = PaginatedMethod(self._wikidotuser_pages) self.wikidotuser_posts = PaginatedMethod(self._wikidotuser_posts) - self.wikidotuser_revisions = PaginatedMethod(self._wikidotuser_revisions) + self.wikidotuser_revisions = PaginatedMethod( + self._wikidotuser_revisions + ) self.tags_pages = PaginatedMethod(self._tags_pages, True) def verbose(self, method: Callable, *args, **kwargs): @@ -107,8 +119,9 @@ def paginated_generator(): offset: int = data['offset'] direction: str = data['direction'] while True: - result = method.verbose(*args, limit=limit, offset=offset, - direction=direction) + result = method.verbose( + *args, limit=limit, offset=offset, direction=direction + ) yield result if length < data['limit']: return @@ -262,14 +275,18 @@ def tag_pages(self, tag: str): return tag, None @endpoint("tag/pages") - def _tags_pages(self, tags: Union[List[int], List[str]], operator: str = 'and', *, data): + def _tags_pages( + self, tags: Union[List[int], List[str]], operator: str = 'and', *, data + ): """ str[] `tags`: A list of tag names. int[] `tags`: A list of SCUTTLE tag IDs. str `operator`: 'and' or 'or'; defines how tags are combined. """ if isinstance(tags, str): - raise TypeError("`tags` must be a list of at least one tag; use tag_pages() for a single tag name") + raise TypeError( + "`tags` must be a list of at least one tag; use tag_pages() for a single tag name" + ) data['operator'] = operator if all(isinstance(tag, str) for tag in tags): data['names'] = tags diff --git a/test/test_api.py b/test/test_api.py index 7c287b8..732e4f8 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -33,6 +33,7 @@ def test_wiki(): assert wiki.wikis()[0]['subdomain'] == "admin" assert wiki.wiki()['subdomain'] == "en" + def test_pagination(): wiki = scuttle.scuttle('en', API_KEY, 1) # will be testing on page revisions pagination @@ -47,23 +48,40 @@ def test_pagination(): assert 'content' in paginated_revisions[0].keys() assert len(paginated_revisions) == 20 + def test_pagination_generator(): wiki = scuttle.scuttle('en', API_KEY, 1) # make a generator page_slug = "main" page_id = wiki.page_by_slug(page_slug)['id'] gen1 = wiki.verbose(wiki.page_revisions, page_id, limit=100) - assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 0 - assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 100 + assert ( + int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) + == 0 + ) + assert ( + int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) + == 100 + ) # make another generator, see if they interfere gen2 = wiki.verbose(wiki.page_revisions, page_id, limit=10, offset=10) - assert int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) == 10 - assert int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) == 20 - assert int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) == 200 + assert ( + int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) + == 10 + ) + assert ( + int(next(gen2)[0]['metadata']['wikidot_metadata']['revision_number']) + == 20 + ) + assert ( + int(next(gen1)[0]['metadata']['wikidot_metadata']['revision_number']) + == 200 + ) # check errors with pytest.raises(TypeError): wiki.verbose(len) + def test_page(): wiki = scuttle.scuttle('en', API_KEY, 1) pages = wiki.pages() @@ -93,6 +111,7 @@ def test_page(): # assert all(page['metadata']['wd_page_created_at'] >= timestamp # for page in pages_since_then) + def test_revisions(): wiki = scuttle.scuttle('en', API_KEY, 1) page_id = wiki.pages()[0]['id'] From 1aa9cd2026bc4ddb0ae9efd105b6c567fb633048 Mon Sep 17 00:00:00 2001 From: Ross Williams Date: Wed, 1 Jul 2020 06:36:49 +0100 Subject: [PATCH 10/10] Add an empty commit to force CI to run