Skip to content

Commit 7055e76

Browse files
authored
Feature: Add pagination to User.get_watched_movies (#115)
2 parents ad370dc + c3f85fc commit 7055e76

5 files changed

Lines changed: 141 additions & 10 deletions

File tree

tests/mock_data/users.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,15 @@
612612
}
613613
]
614614
},
615+
"users/sean/watched/movies?page=1&limit=1": {
616+
"GET": [
617+
{
618+
"plays":4,
619+
"last_watched_at":"2014-10-11T17:00:54.000Z",
620+
"movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":6,"slug":"batman-begins-2005","imdb":"tt0372784","tmdb":272}}
621+
}
622+
]
623+
},
615624
"users/sean/watched/shows": {
616625
"GET": [
617626
{

tests/test_users.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
import pytest
23
from trakt.movies import Movie
34
from trakt.people import Person
45
from trakt.tv import TVEpisode, TVSeason, TVShow
@@ -132,6 +133,35 @@ def test_get_watched_movies():
132133
assert all([isinstance(m, Movie) for m in watched_movies])
133134

134135

136+
def test_get_watched_movies_with_pagination():
137+
sean = User('sean')
138+
watched_movies = sean.get_watched_movies(page=1, limit=1)
139+
assert isinstance(watched_movies, list)
140+
assert len(watched_movies) == 1
141+
assert isinstance(watched_movies[0], Movie)
142+
assert watched_movies[0].title == 'Batman Begins'
143+
144+
145+
def test_get_watched_movies_invalid_page():
146+
sean = User('sean')
147+
with pytest.raises(ValueError):
148+
sean.get_watched_movies(page='bad')
149+
with pytest.raises(ValueError):
150+
sean.get_watched_movies(page=0)
151+
with pytest.raises(ValueError):
152+
sean.get_watched_movies(page=True)
153+
154+
155+
def test_get_watched_movies_invalid_limit():
156+
sean = User('sean')
157+
with pytest.raises(ValueError):
158+
sean.get_watched_movies(limit='bad')
159+
with pytest.raises(ValueError):
160+
sean.get_watched_movies(limit=-1)
161+
with pytest.raises(ValueError):
162+
sean.get_watched_movies(limit=False)
163+
164+
135165
def test_stats():
136166
sean = User('sean')
137167
assert isinstance(sean.get_stats(), dict)

tests/test_utils.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"""unit tests for the trakt.utils module"""
33
from datetime import datetime
44

5-
from trakt.utils import airs_date, extract_ids, now, slugify, timestamp
5+
from trakt.utils import (airs_date, build_uri, extract_ids, now, slugify,
6+
timestamp)
67

78

89
def test_slugify():
@@ -61,3 +62,35 @@ def test_extract_ids():
6162
input_dict = {'ids': ids}
6263
result = extract_ids(input_dict)
6364
assert result == ids
65+
66+
67+
def test_build_uri():
68+
"""verify that uri query params are appended correctly"""
69+
assert build_uri('users/{user}/watched/movies', user='sean') == \
70+
'users/sean/watched/movies'
71+
assert build_uri('shows/popular', page=2, limit=10) == \
72+
'shows/popular?page=2&limit=10'
73+
assert build_uri('shows/popular?extended={extended}', extended='full',
74+
page=2) == 'shows/popular?extended=full&page=2'
75+
76+
77+
def test_build_uri_pagination_validation():
78+
"""verify that pagination params are validated by the helper"""
79+
from pytest import raises
80+
81+
with raises(ValueError, match='page must be a positive integer'):
82+
build_uri('shows/popular', page=0)
83+
84+
with raises(ValueError, match='limit must be a valid integer'):
85+
build_uri('shows/popular', limit='invalid')
86+
87+
88+
def test_validate_pagination_param_rejects_bools_and_fractional_floats():
89+
"""verify pagination validation rejects values that should not coerce"""
90+
from pytest import raises
91+
92+
with raises(ValueError, match='page must be a valid integer'):
93+
build_uri('shows/popular', page=True)
94+
95+
with raises(ValueError, match='limit must be a valid integer'):
96+
build_uri('shows/popular', limit=1.5)

trakt/users.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from trakt.movies import Movie
1010
from trakt.people import Person
1111
from trakt.tv import TVEpisode, TVSeason, TVShow
12-
from trakt.utils import slugify
12+
from trakt.utils import build_uri, slugify
1313

1414
__author__ = 'Jon Nappi'
1515
__all__ = ['User', 'UserList', 'PublicList', 'Request', 'follow', 'get_all_requests',
@@ -523,15 +523,21 @@ def _build_watched_movies(self, data):
523523
return watched_movies
524524

525525
@get
526-
def get_watched_movies(self):
527-
"""Watched progress for all :class:`Movie` objects for this
528-
:class:`User`.
526+
def get_watched_movies(self, page=None, limit=None):
527+
"""Watched progress for :class:`Movie` objects for this :class:`User`.
529528
530-
:return: List of :class:`Movie` instances
529+
:param page: Optional page number for pagination.
530+
:param limit: Optional number of items per page.
531+
:return: List of :class:`Movie` instances for the requested page
531532
"""
532-
data = yield 'users/{user}/watched/movies'.format(
533-
user=slugify(self.username)
533+
uri = build_uri(
534+
'users/{user}/watched/movies',
535+
user=slugify(self.username),
536+
page=page,
537+
limit=limit,
534538
)
539+
540+
data = yield uri
535541
yield self._build_watched_movies(data)
536542

537543
@property

trakt/utils.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import re
33
import unicodedata
44
from datetime import datetime, timezone
5+
from urllib.parse import urlencode
56

6-
__author__ = 'Jon Nappi'
7-
__all__ = ['slugify', 'airs_date', 'now', 'timestamp', 'extract_ids']
7+
__author__ = 'Jon Nappi, Elan Ruusamäe'
8+
__all__ = ['slugify', 'airs_date', 'now', 'timestamp', 'extract_ids',
9+
'build_uri']
810

911

1012
def slugify(value):
@@ -56,3 +58,54 @@ def extract_ids(id_dict):
5658
"""
5759
id_dict.update(id_dict.pop('ids', {}))
5860
return id_dict
61+
62+
63+
def _validate_pagination_param(name, value):
64+
"""Validate and coerce a pagination parameter to a positive integer.
65+
66+
:param name: Parameter name used in error messages.
67+
:param value: Value to validate.
68+
:return: The validated integer value.
69+
:raises ValueError: If value is not a valid positive integer.
70+
"""
71+
72+
try:
73+
# bool is a subclass of int; reject it explicitly to avoid accepting True/False.
74+
if isinstance(value, bool):
75+
raise ValueError
76+
77+
# Avoid silently truncating noninteger floats (e.g., 1.9 -> 1).
78+
if isinstance(value, float) and not value.is_integer():
79+
raise ValueError
80+
81+
value = int(value)
82+
except (TypeError, ValueError):
83+
raise ValueError(f'{name} must be a valid integer')
84+
85+
if value < 1:
86+
raise ValueError(f'{name} must be a positive integer')
87+
88+
return value
89+
90+
91+
def build_uri(uri, page=None, limit=None, **params):
92+
"""Format *uri* and append pagination query parameters.
93+
94+
``page`` and ``limit`` are validated as positive integers and become query
95+
parameters. Remaining keyword arguments are applied via ``str.format`` on
96+
*uri*; any ``None`` values are dropped before formatting (so a missing
97+
placeholder will raise ``KeyError``).
98+
"""
99+
params = {key: value for key, value in params.items() if value is not None}
100+
uri = uri.format(**params)
101+
102+
query = []
103+
if page is not None:
104+
query.append(('page', _validate_pagination_param('page', page)))
105+
if limit is not None:
106+
query.append(('limit', _validate_pagination_param('limit', limit)))
107+
108+
if query:
109+
uri += ('&' if '?' in uri else '?') + urlencode(query)
110+
111+
return uri

0 commit comments

Comments
 (0)