Skip to content

Commit 35db6d0

Browse files
authored
Feature: Auto-paginate User.watched_movies (#117)
2 parents 588e7a4 + 81848ec commit 35db6d0

3 files changed

Lines changed: 61 additions & 13 deletions

File tree

tests/conftest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def __init__(self):
3333
with open(mock_file, encoding='utf-8') as f:
3434
self.mock_data.update(json.load(f))
3535

36-
def request(self, method, uri, data=None):
36+
def request(self, method, uri, data=None, include_headers=False):
3737
if uri.startswith('/'):
3838
uri = uri[1:]
3939
# use a deepcopy of the mocked data to ensure clean responses on every
@@ -42,7 +42,10 @@ def request(self, method, uri, data=None):
4242
response = method_responses.get(method.upper())
4343
if response is None:
4444
print(f"No mock for {uri}")
45-
return deepcopy(response)
45+
response = deepcopy(response)
46+
if include_headers:
47+
return response, {}
48+
return response
4649

4750

4851
trakt.core.CLIENT_ID = 'FOO'

trakt/api.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from trakt.core import TIMEOUT
1313
from trakt.errors import (BadRequestException, BadResponseException,
1414
OAuthException, OAuthRefreshException)
15+
from trakt.utils import build_uri
1516

1617
__author__ = 'Elan Ruusamäe'
1718

@@ -39,20 +40,23 @@ def __init__(self, base_url: str, session: Session, timeout=None):
3940
self.session = session
4041
self.timeout = timeout or TIMEOUT
4142

42-
def get(self, url: str):
43+
def get(self, url: str, include_headers=False):
4344
"""
4445
Send a GET request to the specified URL.
4546
4647
Parameters:
4748
url (str): The endpoint URL to send the GET request to.
49+
include_headers (bool): When true, return a ``(response, headers)``
50+
tuple instead of just the decoded JSON body.
4851
4952
Returns:
50-
dict: The JSON-decoded response from the server.
53+
dict or tuple: The JSON-decoded response from the server, or a
54+
``(response, headers)`` tuple when ``include_headers`` is true.
5155
5256
Raises:
5357
Various exceptions from `raise_if_needed` based on HTTP status codes.
5458
"""
55-
return self.request('get', url)
59+
return self.request('get', url, include_headers=include_headers)
5660

5761
def delete(self, url: str):
5862
self.request('delete', url)
@@ -76,6 +80,35 @@ def put(self, url: str, data):
7680
"""
7781
return self.request('put', url, data=data)
7882

83+
def iter_pages(self, url, **params):
84+
"""Yield successive pages for a paginated GET endpoint."""
85+
page = 1
86+
while True:
87+
page_url = build_uri(url, **params) if page == 1 else build_uri(
88+
url, page=page, **params
89+
)
90+
page_data, headers = self.get(page_url, include_headers=True)
91+
yield page_data
92+
try:
93+
page_count = int(headers.get('X-Pagination-Page-Count', 1))
94+
except (TypeError, ValueError):
95+
page_count = 1
96+
if page >= page_count:
97+
break
98+
page += 1
99+
100+
def paginate(self, url, **params):
101+
"""Return a flattened list from all pages of a paginated GET endpoint."""
102+
results = []
103+
for page_data in self.iter_pages(url, **params):
104+
if page_data is None:
105+
continue
106+
if isinstance(page_data, list):
107+
results.extend(page_data)
108+
else:
109+
results.append(page_data)
110+
return results
111+
79112
@property
80113
def auth(self):
81114
"""
@@ -96,7 +129,7 @@ def auth(self, auth):
96129
"""
97130
self._auth = auth
98131

99-
def request(self, method, url, data=None):
132+
def request(self, method, url, data=None, include_headers=False):
100133
"""
101134
Send an HTTP request to the Trakt API and process the response.
102135
@@ -109,7 +142,9 @@ def request(self, method, url, data=None):
109142
data (dict, optional): Payload to send with the request. Defaults to None.
110143
111144
Returns:
112-
dict or None: Decoded JSON response from the Trakt API, or None for 204 No Content responses
145+
dict or tuple or None: Decoded JSON response from the Trakt API,
146+
``(response, headers)`` when ``include_headers`` is true, or None
147+
for 204 No Content responses.
113148
114149
Raises:
115150
TraktException: If the API returns a non-200 status code
@@ -130,11 +165,16 @@ def request(self, method, url, data=None):
130165
else:
131166
response = self.session.request(method, url, headers=self.headers, auth=self.auth, timeout=self.timeout, data=json.dumps(data))
132167
self.logger.debug('RESPONSE [%s] (%s): %s', method, url, str(response))
168+
headers = response.headers.copy()
133169
if response.status_code == 204: # HTTP no content
134-
return None
135-
self.raise_if_needed(response)
170+
body = None
171+
else:
172+
self.raise_if_needed(response)
173+
body = self.decode_response(response)
136174

137-
return self.decode_response(response)
175+
if include_headers:
176+
return body, headers
177+
return body
138178

139179
@staticmethod
140180
def decode_response(response):

trakt/users.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass, fields
55
from typing import Any, NamedTuple, Optional, Union
66

7-
from trakt.core import delete, get, post
7+
from trakt.core import api, delete, get, post
88
from trakt.mixins import DataClassMixin, IdsMixin
99
from trakt.movies import Movie
1010
from trakt.people import Person
@@ -545,10 +545,15 @@ def get_watched_movies(self, page=None, limit=None):
545545
@property
546546
def watched_movies(self):
547547
"""Watched progress for all :class:`Movie`'s in this :class:`User`'s
548-
collection.
548+
collection. Automatically fetches all pages.
549549
"""
550550
if self._watched_movies is None:
551-
self._watched_movies = self.get_watched_movies()
551+
client = api()
552+
all_movies = client.paginate(
553+
'users/{user}/watched/movies',
554+
user=slugify(self.username),
555+
)
556+
self._watched_movies = self._build_watched_movies(all_movies or [])
552557
return self._watched_movies
553558

554559
@property

0 commit comments

Comments
 (0)