Repository: niconoe/pyinaturalist
Pull request #278 (2021-09-13 20:39:29)

Pagination improvements
Updates #276

- Exclude most pagination params from controller method signatures, in favor of using `Paginator` instead
- Update `Paginator` to support request positional args
- Fix ID-based pagination; `total_results` is only accurate for first page, since subsequent pages have decreasing `total_results` due to `id_above` param
- Move `paginate_autocomplete()` to `places` module, since that's the only place it's used
- Handle special pagination for `get_observation_fields()` within that function instead of trying to handle it generically in Paginator
- Remove `paginate_all()` support for V0 observations.json
- Update remaining API request functions to call `paginate_all()` instead of (harder to follow) `@add_paginate_all` decorator
- Move request estimating to separate `Paginator.count()` method instead of automagically doing it with `total_results` property


Patch:
diff --git a/pyinaturalist/docs/signatures.py b/pyinaturalist/docs/signatures.py
index d4a210e..c2517c5 100644
--- a/pyinaturalist/docs/signatures.py
+++ b/pyinaturalist/docs/signatures.py
@@ -12,7 +12,8 @@ from pyinaturalist.converters import ensure_list
 from pyinaturalist.docs import copy_annotations, copy_docstrings
 
 AUTOMETHOD_INIT = '.. automethod:: __init__'
-COMMON_PARAMS = ['dry_run', 'session']
+CONTROLLER_EXCLUDE_PARAMS = ['dry_run', 'session', 'page', 'per_page', 'order', 'count_only']
+
 logger = getLogger(__name__)
 
 
@@ -82,7 +83,7 @@ document_controller_params = partial(
     copy_doc_signature,
     include_sections=['Description', 'Args'],
     include_return_annotation=False,
-    exclude_args=COMMON_PARAMS,
+    exclude_args=CONTROLLER_EXCLUDE_PARAMS,
 )
 
 
diff --git a/pyinaturalist/paginator.py b/pyinaturalist/paginator.py
index 1c551c7..b016258 100644
--- a/pyinaturalist/paginator.py
+++ b/pyinaturalist/paginator.py
@@ -1,8 +1,18 @@
 from concurrent.futures import ThreadPoolExecutor
-from functools import wraps
 from logging import getLogger
 from math import ceil
-from typing import AsyncIterable, AsyncIterator, Callable, Generic, Iterable, Iterator, List
+from typing import (
+    AsyncIterable,
+    AsyncIterator,
+    Callable,
+    Generic,
+    Iterable,
+    Iterator,
+    List,
+    Optional,
+)
+
+from requests import Response
 
 from pyinaturalist.constants import (
     EXPORT_URL,
@@ -17,23 +27,24 @@ from pyinaturalist.models import T
 logger = getLogger(__name__)
 
 
-# TODO: support autocomplete pseudo-pagination?
 class Paginator(Iterable, AsyncIterable, Generic[T]):
     """Class to handle pagination of API requests, with async support
 
     Args:
         request_function: API request function to paginate
         model: Model class to use for results
-        method: Pagination method; either 'page', 'id', or 'autocomplete' (see below)
+        method: Pagination method; either 'page' or 'id' (see note below)
         limit: Maximum number of results to fetch
         per_page: Maximum number of results to fetch per page
         kwargs: Original request parameters
 
 
-    Note on pagination by ID, from the iNaturalist documentation:
-    _'The large size of the observations index prevents us from supporting the page parameter when
-    retrieving records from large result sets. If you need to retrieve large numbers of records,
-    use the ``per_page`` and ``id_above`` or ``id_below`` parameters instead.'_
+    .. note::
+        Note on pagination by ID, from the iNaturalist documentation:
+
+        *The large size of the observations index prevents us from supporting the page parameter
+        when retrieving records from large result sets. If you need to retrieve large numbers of
+        records, use the ``per_page`` and ``id_above`` or ``id_below`` parameters instead.*
 
     """
 
@@ -41,6 +52,7 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         self,
         request_function: Callable,
         model: T,
+        *request_args,
         method: str = 'page',
         limit: int = None,
         per_page: int = None,
@@ -49,19 +61,18 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         self.limit = limit
         self.kwargs = kwargs
         self.request_function = request_function
+        self.request_args = request_args
         self.method = method
         self.model = model
         self.per_page = per_page or PER_PAGE_RESULTS
 
         self.exhausted = False
         self.results_fetched = 0
-        self._total_results = -1
+        self.total_results: Optional[int] = None
 
         # Set initial pagination params based on pagination method
         self.kwargs.pop('page', None)
         self.kwargs.pop('per_page', None)
-        # if self.method == 'autocomplete':
-        #     return paginate_autocomplete(self.request_function, **self.kwargs)
         if self.method == 'id':
             self.kwargs['order_by'] = 'id'
             self.kwargs['order'] = 'asc'
@@ -81,20 +92,22 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
             for result in self.next_page():
                 yield self.model.from_json(result)
 
-    @property
-    def total_results(self) -> int:
-        """Total number of results for this query. Will be fetched if no pages have been fetched
-        yet.
-        """
-        if self._total_results == -1:
-            count_response = self.request_function(**{**self.kwargs, 'per_page': 0})
-            self._total_results = int(count_response['total_results'])
-        return self._total_results
-
     def all(self) -> List[T]:
         """Get all results in a single list"""
         return list(self)
 
+    def count(self) -> int:
+        """Get the total number of results for this query, without fetching response data.
+
+        Returns:
+            Either the total number of results, if the endpoint provides pagination info, or ``-1``
+        """
+        if self.total_results is None:
+            kwargs = {**self.kwargs, 'per_page': 0}
+            count_response = self.request_function(*self.request_args, **kwargs)
+            self.total_results = int(count_response['total_results'])
+        return self.total_results
+
     def next_page(self) -> List[ResponseResult]:
         """Get the next page of results"""
         if self.exhausted:
@@ -104,31 +117,37 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
         if self.limit and self.results_fetched + self.per_page > self.limit:
             self.per_page = self.limit - self.results_fetched
 
-        # Fetch results
-        response = self.request_function(**self.kwargs, per_page=self.per_page)
+        # Fetch results; handle response object or dict
+        response = self.request_function(*self.request_args, **self.kwargs, per_page=self.per_page)
+        if isinstance(response, Response):
+            response = response.json()
         results = response.get('results', response)
-        self._total_results = response.get('total_results', 0)
+
+        # Note: For id-based pagination, only the first page's 'total_results' is accurate
+        if self.total_results is None:
+            self.total_results = response.get('total_results', len(results))
         self.results_fetched += len(results)
+        self._update_next_page_params(results)
 
-        # Set params for next request, if there are more results
-        # Some endpoints (like get_observation_fields) don't return total_results
-        # Also check page size, in case total_results is off (race condition, outdated index, etc.)
+        # If this is the first of multiple requests, log the estimated time and number of requests
+        if self.results_fetched == len(results) and not self.exhausted:
+            self._estimate()
+        return results
+
+    def _update_next_page_params(self, page_results):
+        """Set params for next request, if there are more results. Also check page size, in case
+        total_results is off due to race condition, outdated index, etc.
+        """
         if (
             (self.limit and self.results_fetched >= self.limit)
             or (self.total_results and self.results_fetched >= self.total_results)
-            or len(results) == 0
+            or len(page_results) == 0
         ):
             self.exhausted = True
+        elif self.method == 'id':
+            self.kwargs['id_above'] = page_results[-1]['id']
         else:
-            if self.method == 'id':
-                self.kwargs['id_above'] = results[-1]['id']
-            else:
-                self.kwargs['page'] += 1
-
-        # If this is the first of multiple requests, log the estimated time and number of requests
-        if self.results_fetched == len(results) and not self.exhausted:
-            self._estimate()
-        return results
+            self.kwargs['page'] += 1
 
     def _estimate(self):
         """Log the estimated total number of requests and rate-limiting delay, and show a warning if
@@ -149,8 +168,8 @@ class Paginator(Iterable, AsyncIterable, Generic[T]):
 
     def __str__(self) -> str:
         return (
-            f'Paginator({self.request_function.__name__}, '
-            f'fetched={self.results_fetched}/{self.total_results})'
+            f'{self.__class__.__name__}({self.request_function.__name__}, '
+            f'fetched={self.results_fetched}/{self.total_results or "unknown"})'
         )
 
 
@@ -171,57 +190,10 @@ class JsonPaginator(Paginator):
         }
 
 
-def add_paginate_all(method: str = 'page'):
-    """Decorator that adds an option ``page='all'`` to get all pages of results for the wrapped API
-    request function.
-    """
-
-    def decorator(request_function: Callable):
-        @wraps(request_function)
-        def wrapper(*args, **kwargs):
-            if kwargs.get('page') == 'all':
-                return paginate_all(request_function, *args, method=method, **kwargs)
-            return request_function(*args, **kwargs)
-
-        return wrapper
-
-    return decorator
-
-
-# TODO: handle *args
 def paginate_all(request_function: Callable, *args, method: str = 'page', **kwargs) -> JsonResponse:
     """Get all pages of a multi-page request. Explicit pagination parameters will be overridden.
 
     Returns:
         Response dict containing combined results, in the same format as ``api_func``
     """
-    if method == 'autocomplete':
-        return paginate_autocomplete(request_function, *args, **kwargs)
-    return JsonPaginator(request_function, model=None, method=method, **kwargs).all()
-
-
-def paginate_autocomplete(api_func: Callable, *args, **kwargs) -> JsonResponse:
-    """Attempt to get as many results as possible from the places autocomplete endpoint.
-    This is necessary for some problematic places for which there are many matches but not ranked
-    with the desired match(es) first.
-
-    This works based on different rankings being returned for order_by=area. No other fields can be
-    sorted on, and direction can't be specified, but this can at least provide a few additional
-    results beyond the limit of 20.
-    """
-    kwargs['per_page'] = 20
-    kwargs.pop('order_by', None)
-
-    # Search with default ordering and ordering by area (if there are more than 20 results)
-    page_1 = api_func(*args, **kwargs)
-    if page_1['total_results'] > 20:
-        page_2 = api_func(*args, **kwargs, order_by='area')
-    else:
-        page_2 = {'results': []}
-
-    # De-duplicate results
-    unique_results = {r['id']: r for page in [page_1, page_2] for r in page['results']}
-    return {
-        'results': list(unique_results.values()),
-        'total_results': page_1['total_results'],
-    }
+    return JsonPaginator(request_function, None, *args, method=method, **kwargs).all()
diff --git a/pyinaturalist/v0/observation_fields.py b/pyinaturalist/v0/observation_fields.py
index 3700d42..933340c 100644
--- a/pyinaturalist/v0/observation_fields.py
+++ b/pyinaturalist/v0/observation_fields.py
@@ -4,12 +4,10 @@ from pyinaturalist.constants import API_V0_BASE_URL, JsonResponse
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.paginator import add_paginate_all
 from pyinaturalist.session import get, put
 
 
 @document_request_params(docs._search_query, docs._pagination)
-@add_paginate_all()
 def get_observation_fields(**params) -> JsonResponse:
     """Search observation fields. Observation fields are basically typed data fields that
     users can attach to observation.
@@ -17,6 +15,7 @@ def get_observation_fields(**params) -> JsonResponse:
     .. rubric:: Notes
 
     * API reference: :v0:`GET /observation_fields <get-observation_fields>`
+    * This endpoint does not support the ``per_page`` parameter
 
     Example:
 
@@ -31,12 +30,23 @@ def get_observation_fields(**params) -> JsonResponse:
             .. literalinclude:: ../sample_data/get_observation_fields.py
 
     Returns:
-        Observation fields as a list of dicts
+        Observation fields
     """
-    response = get(f'{API_V0_BASE_URL}/observation_fields.json', **params)
-    obs_fields = response.json()
+    all_pages = params.pop('page', None)
+    params['page'] = 1
+    params['per_page'] = 30  # This seems to return 30 results regardless of the per_page param
+    obs_fields = get(f'{API_V0_BASE_URL}/observation_fields.json', **params).json()
+
+    # This endpoint returns a list with no pagination metadata, so iterate until there are no more pages
+    if all_pages:
+        page_results = obs_fields
+        while len(page_results) == 30:
+            params['page'] += 1
+            page_results = get(f'{API_V0_BASE_URL}/observation_fields.json', **params).json()
+            obs_fields.extend(page_results)
+
     obs_fields = convert_all_timestamps(obs_fields)
-    return {'results': obs_fields}
+    return {'results': obs_fields, 'total_results': len(obs_fields)}
 
 
 @document_request_params(docs._ofvs, docs._access_token)
diff --git a/pyinaturalist/v0/observations.py b/pyinaturalist/v0/observations.py
index 927870e..9a27d6f 100644
--- a/pyinaturalist/v0/observations.py
+++ b/pyinaturalist/v0/observations.py
@@ -12,7 +12,6 @@ from pyinaturalist.converters import convert_all_coordinates, convert_all_timest
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
 from pyinaturalist.exceptions import ObservationNotFound
-from pyinaturalist.paginator import add_paginate_all
 from pyinaturalist.request_params import convert_observation_params, validate_multiple_choice_param
 from pyinaturalist.session import delete, get, post, put
 
@@ -25,7 +24,6 @@ logger = getLogger(__name__)
     docs._bounding_box,
     docs._pagination,
 )
-@add_paginate_all()
 def get_observations(**params) -> Union[List, str]:
     """Get observation data, optionally in an alternative format
 
diff --git a/pyinaturalist/v1/identifications.py b/pyinaturalist/v1/identifications.py
index 1c6543f..9bfa940 100644
--- a/pyinaturalist/v1/identifications.py
+++ b/pyinaturalist/v1/identifications.py
@@ -2,7 +2,7 @@ from pyinaturalist.constants import JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.paginator import add_paginate_all
+from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_rank_range
 from pyinaturalist.v1 import get_v1
 
@@ -35,7 +35,6 @@ def get_identifications_by_id(identification_id: MultiInt, **params) -> JsonResp
 
 
 @document_request_params(docs._identification_params, docs._pagination, docs._only_id)
-@add_paginate_all()
 def get_identifications(**params) -> JsonResponse:
     """Search identifications
 
@@ -62,7 +61,10 @@ def get_identifications(**params) -> JsonResponse:
         Response dict containing identification records
     """
     params = convert_rank_range(params)
-    response = get_v1('identifications', **params)
-    identifications = response.json()
+    if params.get('page') == 'all':
+        identifications = paginate_all(get_v1, 'identifications', **params)
+    else:
+        identifications = get_v1('identifications', **params).json()
+
     identifications['results'] = convert_all_timestamps(identifications['results'])
     return identifications
diff --git a/pyinaturalist/v1/observations.py b/pyinaturalist/v1/observations.py
index 8c14a26..ff8fc32 100644
--- a/pyinaturalist/v1/observations.py
+++ b/pyinaturalist/v1/observations.py
@@ -17,7 +17,7 @@ from pyinaturalist.converters import (
 from pyinaturalist.docs import document_common_args, document_request_params
 from pyinaturalist.docs import templates as docs
 from pyinaturalist.exceptions import ObservationNotFound
-from pyinaturalist.paginator import add_paginate_all
+from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_observation_params, validate_multiple_choice_param
 from pyinaturalist.v1 import delete_v1, get_v1, post_v1, put_v1
 
@@ -109,7 +109,6 @@ def get_observation_histogram(**params) -> HistogramResponse:
 
 
 @document_request_params(*docs._get_observations, docs._pagination, docs._only_id)
-@add_paginate_all(method='id')
 def get_observations(**params) -> JsonResponse:
     """Search observations
 
@@ -146,17 +145,18 @@ def get_observations(**params) -> JsonResponse:
         Response dict containing observation records
     """
     validate_multiple_choice_param(params, 'order_by', V1_OBS_ORDER_BY_PROPERTIES)
-    response = get_v1('observations', **params)
 
-    observations = response.json()
+    if params.get('page') == 'all':
+        observations = paginate_all(get_v1, 'observations', method='id', **params)
+    else:
+        observations = get_v1('observations', **params).json()
+
     observations['results'] = convert_all_coordinates(observations['results'])
     observations['results'] = convert_all_timestamps(observations['results'])
-
     return observations
 
 
 @document_request_params(*docs._get_observations, docs._pagination)
-@add_paginate_all()
 def get_observation_species_counts(**params) -> JsonResponse:
     """Get all species (or other 'leaf taxa') associated with observations matching the search
     criteria, and the count of observations they are associated with.
@@ -182,8 +182,10 @@ def get_observation_species_counts(**params) -> JsonResponse:
     Returns:
         Response dict containing taxon records with counts
     """
-    response = get_v1('observations/species_counts', **params)
-    return response.json()
+    if params.get('page') == 'all':
+        return paginate_all(get_v1, 'observations/species_counts', **params)
+    else:
+        return get_v1('observations/species_counts', **params).json()
 
 
 @document_request_params(*docs._get_observations, docs._pagination)
@@ -251,7 +253,6 @@ def get_observation_identifiers(**params) -> JsonResponse:
     return response.json()
 
 
-@add_paginate_all()
 def get_observation_taxonomy(user_id: IntOrStr = None, **params) -> JsonResponse:
     """Get observation counts for all taxa in a full taxonomic tree. In the web UI, these are used
     for life lists.
@@ -271,8 +272,10 @@ def get_observation_taxonomy(user_id: IntOrStr = None, **params) -> JsonResponse
     Returns:
         Response dict containing taxon records with counts
     """
-    response = get_v1('observations/taxonomy', user_id=user_id, **params)
-    return response.json()
+    if params.get('page') == 'all':
+        return paginate_all(get_v1, 'observations/taxonomy', user_id=user_id, **params)
+    else:
+        return get_v1('observations/taxonomy', user_id=user_id, **params).json()
 
 
 @document_common_args
diff --git a/pyinaturalist/v1/places.py b/pyinaturalist/v1/places.py
index d82b1ea..3ecb3a8 100644
--- a/pyinaturalist/v1/places.py
+++ b/pyinaturalist/v1/places.py
@@ -2,7 +2,6 @@ from pyinaturalist.constants import JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_coordinates, convert_all_place_coordinates
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.paginator import add_paginate_all
 from pyinaturalist.v1 import get_v1
 
 
@@ -78,7 +77,6 @@ def get_places_nearby(**params) -> JsonResponse:
 
 
 @document_request_params(docs._search_query, docs._pagination)
-@add_paginate_all(method='autocomplete')
 def get_places_autocomplete(q: str = None, **params) -> JsonResponse:
     """Given a query string, get places with names starting with the search term
 
@@ -105,9 +103,37 @@ def get_places_autocomplete(q: str = None, **params) -> JsonResponse:
     Returns:
         Response dict containing place records
     """
-    response = get_v1('places/autocomplete', q=q, **params)
+    if params.get('page') == 'all':
+        places = paginate_autocomplete(q=q, **params)
+    else:
+        places = get_v1('places/autocomplete', q=q, **params).json()
 
-    # Convert coordinates to floats
-    places = response.json()
     places['results'] = convert_all_coordinates(places['results'])
     return places
+
+
+def paginate_autocomplete(**params) -> JsonResponse:
+    """Attempt to get as many results as possible from the places autocomplete endpoint.
+    This is necessary for some problematic places for which there are many matches but not ranked
+    with the desired match(es) first.
+
+    This works based on different rankings being returned for order_by=area. No other fields can be
+    sorted on, and direction can't be specified, but this can at least provide a few additional
+    results beyond the limit of 20.
+    """
+    params['per_page'] = 20
+    params.pop('order_by', None)
+
+    # Search with default ordering and ordering by area (if there are more than 20 results)
+    page_1 = get_v1('places/autocomplete', **params).json()
+    if page_1['total_results'] > 20:
+        page_2 = get_v1('places/autocomplete', order_by='area', **params).json()
+    else:
+        page_2 = {'results': []}
+
+    # De-duplicate results
+    unique_results = {r['id']: r for page in [page_1, page_2] for r in page['results']}
+    return {
+        'results': list(unique_results.values()),
+        'total_results': page_1['total_results'],
+    }
diff --git a/pyinaturalist/v1/projects.py b/pyinaturalist/v1/projects.py
index 7b13063..52e6cec 100644
--- a/pyinaturalist/v1/projects.py
+++ b/pyinaturalist/v1/projects.py
@@ -2,13 +2,12 @@ from pyinaturalist.constants import PROJECT_ORDER_BY_PROPERTIES, JsonResponse, M
 from pyinaturalist.converters import convert_all_coordinates, convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.paginator import add_paginate_all
+from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import validate_multiple_choice_param
 from pyinaturalist.v1 import delete_v1, get_v1, post_v1
 
 
 @document_request_params(docs._projects_params, docs._pagination)
-@add_paginate_all()
 def get_projects(**params) -> JsonResponse:
     """Search projects
 
@@ -44,9 +43,11 @@ def get_projects(**params) -> JsonResponse:
         Response dict containing project records
     """
     validate_multiple_choice_param(params, 'order_by', PROJECT_ORDER_BY_PROPERTIES)
-    response = get_v1('projects', **params)
+    if params.get('page') == 'all':
+        projects = paginate_all(get_v1, 'projects', **params)
+    else:
+        projects = get_v1('projects', **params).json()
 
-    projects = response.json()
     projects['results'] = convert_all_coordinates(projects['results'])
     projects['results'] = convert_all_timestamps(projects['results'])
     return projects
diff --git a/pyinaturalist/v1/taxa.py b/pyinaturalist/v1/taxa.py
index e1ce79f..77d017b 100644
--- a/pyinaturalist/v1/taxa.py
+++ b/pyinaturalist/v1/taxa.py
@@ -2,13 +2,12 @@ from pyinaturalist.constants import JsonResponse, MultiInt
 from pyinaturalist.converters import convert_all_timestamps
 from pyinaturalist.docs import document_request_params
 from pyinaturalist.docs import templates as docs
-from pyinaturalist.paginator import add_paginate_all
+from pyinaturalist.paginator import paginate_all
 from pyinaturalist.request_params import convert_rank_range
 from pyinaturalist.v1 import get_v1
 
 
 @document_request_params(docs._taxon_params, docs._taxon_id_params, docs._pagination)
-@add_paginate_all()
 def get_taxa(**params) -> JsonResponse:
     """Search taxa
 
@@ -34,8 +33,11 @@ def get_taxa(**params) -> JsonResponse:
         Response dict containing taxon records
     """
     params = convert_rank_range(params)
-    response = get_v1('taxa', **params)
-    taxa = response.json()
+    if params.get('page') == 'all':
+        taxa = paginate_all(get_v1, 'taxa', **params)
+    else:
+        taxa = get_v1('taxa', **params).json()
+
     taxa['results'] = convert_all_timestamps(taxa['results'])
     return taxa
 


Test patch:
diff --git a/test/test_paginator.py b/test/test_paginator.py
index eb6eaf2..0f4f7c4 100644
--- a/test/test_paginator.py
+++ b/test/test_paginator.py
@@ -50,13 +50,16 @@ async def test_async_iter(requests_mock):
     assert len(observations) == 2
 
 
-def test_total_results_count(requests_mock):
+def test_count(requests_mock):
     requests_mock.get(
         f'{API_V1_BASE_URL}/observations?per_page=0', json={'results': [], 'total_results': 50}
     )
 
     paginator = Paginator(get_observations, Observation, q='asdf')
-    assert paginator.total_results == 50
+    assert paginator.count() == 50
+
+    # Subsequent calls should use the previously saved value
+    assert paginator.count() == paginator.total_results == 50
 
 
 def test_next_page__exhausted():
@@ -71,4 +74,7 @@ def test_str():
 
     paginator = Paginator(get_observations, Observation)
     assert 'get_observations' in str(paginator)
-    assert '0/0' in str(paginator)
+    assert '0/unknown' in str(paginator)
+
+    paginator.total_results = 50
+    assert '0/50' in str(paginator)
diff --git a/test/v0/test_observation_fields_v0.py b/test/v0/test_observation_fields_v0.py
index 8f4dbba..e15e29e 100644
--- a/test/v0/test_observation_fields_v0.py
+++ b/test/v0/test_observation_fields_v0.py
@@ -10,7 +10,7 @@ from test.sample_data import SAMPLE_DATA
 def test_get_observation_fields(requests_mock):
     """get_observation_fields() work as expected (basic use)"""
     requests_mock.get(
-        f'{API_V0_BASE_URL}/observation_fields.json?q=sex&page=2',
+        f'{API_V0_BASE_URL}/observation_fields.json?q=sex',
         json=SAMPLE_DATA['get_observation_fields_page2'],
         status_code=200,
     )