Repository: linode/linode_api4-python

Issue:
Undocumented valid argument "tag" in client.instance_create() function and undocumented requirements for client.create_tag() function
As per the title, there's missing documentation entries for two functions:
- The "tag" parameter in the instance_create() function of the LinodeClient() class
- The user permissions requirements for the tag_create() class

This led to a lot of trial and error on my part when I tried to create a new instance with a new tag in a _restricted account_.

The docs clearly say that the [client.tags() function requires an unrestricted account](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.LinodeClient.tags). However, the [entry for client.tag_create()](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.LinodeClient.tag_create) does not, and causes and error when called on by a restricted user. 
# Example
When trying to create a new tag and apply it to all or any instances:
```
client = LinodeClient(settings.get("API_key"))
instance = client.instance_create(.....)
client.tag_create(label="random", instances=instance)
```

This results in a 403 response from the API:
```
Traceback (most recent call last):
  File "./deploy.py", line 53, in <module>
    main()
  File "./deploy.py", line 31, in main
    tags = client.tag_create(label="random")
  File "/usr/local/lib/python3.7/dist-packages/linode_api4/linode_client.py", line 1408, in tag_create
    result = self.post('/tags', data=params)
  File "/usr/local/lib/python3.7/dist-packages/linode_api4/linode_client.py", line 1185, in post
    return self._api_call(*args, method=self.session.post, **kwargs)
  File "/usr/local/lib/python3.7/dist-packages/linode_api4/linode_client.py", line 1156, in _api_call
    raise ApiError(error_msg, status=response.status_code, json=j)
linode_api4.errors.ApiError: 403: Unauthorized;
```
# counterexample
This is not to say that new instances cannot be tagged: the alternative is to specify the tag when creating a new instance, but this is missing from the documentation.
Here's some example code that has worked for me:
```
[...]
client = LinodeClient(settings.get("API_key"))
new_linode, new_password = client.linode.instance_create(settings.get("type"), 
                                                    settings.get("region"), 
                                                    tags=['random'], 
                                                    image=deployment_image, 
                                                    authorized_keys=settings.get("ssh").get("key"), 
                                                    label=label, 
                                                    booted=True)
```

However, this is *not documented* in the [entry for the instance_create() function](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.linode_client.LinodeGroup.instance_create) 

# Suggestions
1) Add a note to the tag_create() class [documentation entry](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.LinodeClient.tag_create) notifying developers of the restriction (similar to how the [client.tags() entry](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.LinodeClient.tags) does)
2) Add the parameter "tags" to the supported list of arguments in the client.instance_create() [documentation](https://linode-api4.readthedocs.io/en/latest/linode_api4/linode_client.html#linode_api4.linode_client.LinodeGroup.instance_create) and [source](https://linode-api4.readthedocs.io/en/latest/_modules/linode_api4/linode_client.html#LinodeGroup.instance_create)

Edit: grammar and spelling

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 9c5621d..ebfe9eb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,7 @@ python:
     - "3.6"
     - "3.7"
     - "3.8"
+    - "3.9"
 dist: xenial
 install:
     - python setup.py install
diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py
index b776ffd..eae0a99 100644
--- a/linode_api4/linode_client.py
+++ b/linode_api4/linode_client.py
@@ -218,6 +218,10 @@ class LinodeGroup(Group):
                        default to True if the Instance is deployed from an Image
                        or Backup.
         :type booted: bool
+        :param tags: A list of tags to apply to the new instance.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
 
         :returns: A new Instance object, or a tuple containing the new Instance and
                   the generated password.
@@ -1015,7 +1019,7 @@ class ObjectStorageGroup(Group):
 
 
 class LinodeClient:
-    def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None):
+    def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None):
         """
         The main interface to the Linode API.
 
@@ -1031,11 +1035,17 @@ class LinodeClient:
                            application.  Setting this is not necessary, but some
                            applications may desire this behavior.
         :type user_agent: str
+        :param page_size: The default size to request pages at.  If not given,
+                                  the API's default page size is used.  Valid values
+                                  can be found in the API docs, but at time of writing
+                                  are between 25 and 500.
+        :type page_size: int
         """
         self.base_url = base_url
         self._add_user_agent = user_agent
         self.token = token
         self.session = requests.Session()
+        self.page_size = page_size
 
         #: Access methods related to Linodes - see :any:`LinodeGroup` for
         #: more information
@@ -1163,7 +1173,12 @@ class LinodeClient:
         return j
 
     def _get_objects(self, endpoint, cls, model=None, parent_id=None, filters=None):
-        response_json = self.get(endpoint, model=model, filters=filters)
+        # handle non-default page sizes
+        call_endpoint = endpoint
+        if self.page_size is not None:
+            call_endpoint += "?page_size={}".format(self.page_size)
+
+        response_json = self.get(call_endpoint, model=model, filters=filters)
 
         if not "data" in response_json:
             raise UnexpectedResponseError("Problem with response!", json=response_json)
@@ -1306,6 +1321,10 @@ class LinodeClient:
         :type domain: str
         :param master: Whether this is a master (defaults to true)
         :type master: bool
+        :param tags: A list of tags to apply to the new domain.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
 
         :returns: The new Domain object.
         :rtype: Domain
@@ -1439,6 +1458,10 @@ class LinodeClient:
         :type linode: Instance or int
         :param size: The size, in GB, of the new Volume.  Defaults to 20.
         :type size: int
+        :param tags: A list of tags to apply to the new volume.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
 
         :returns: The new Volume.
         :rtype: Volume
diff --git a/linode_api4/paginated_list.py b/linode_api4/paginated_list.py
index 8d09d03..c6b51b6 100644
--- a/linode_api4/paginated_list.py
+++ b/linode_api4/paginated_list.py
@@ -37,7 +37,8 @@ class PaginatedList(object):
         self.page_size = len(page)
         self.max_pages = max_pages
         self.lists = [ None for _ in range(0, self.max_pages) ]
-        self.lists[0] = page
+        if self.lists:
+            self.lists[0] = page
         self.list_cls = type(page[0]) if page else None # TODO if this is None that's bad
         self.objects_parent_id = parent_id
         self.cur = 0 # for being a generator
@@ -84,7 +85,7 @@ class PaginatedList(object):
         return "PaginatedList ({} items)".format(self.total_items)
 
     def _load_page(self, page_number):
-        j = self.client.get("/{}?page={}".format(self.page_endpoint, page_number+1),
+        j = self.client.get("/{}?page={}&page_size={}".format(self.page_endpoint, page_number+1, self.page_size),
                 filters=self.query_filters)
 
         if j['pages'] != self.max_pages or j['results'] != len(self):
diff --git a/setup.py b/setup.py
index 0d25334..b8739d9 100755
--- a/setup.py
+++ b/setup.py
@@ -31,7 +31,7 @@ setup(
     # Versions should comply with PEP440.  For a discussion on single-sourcing
     # the version across setup.py and the project code, see
     # https://packaging.python.org/en/latest/single_source_version.html
-    version='3.0.2',
+    version='3.0.4',
 
     description='The official python SDK for Linode API v4',
     long_description=long_description,


Test patch:
diff --git a/test/paginated_list_test.py b/test/paginated_list_test.py
index 0a411de..77643a5 100644
--- a/test/paginated_list_test.py
+++ b/test/paginated_list_test.py
@@ -1,4 +1,5 @@
 from unittest import TestCase
+from unittest.mock import MagicMock, call
 
 from linode_api4.paginated_list import PaginatedList
 
@@ -76,3 +77,52 @@ class PaginationSlicingTest(TestCase):
         Tests that backwards indexing works as expected
         """
         self.assertEqual(self.normal_list[10:5], self.paginated_list[10:5])
+
+
+class TestModel():
+    """
+    This is a test model class used to simulate an actual model that would be
+    returned by the API
+    """
+    @classmethod
+    def make_instance(*args, **kwargs):
+        return TestModel()
+
+
+class PageLoadingTest(TestCase):
+    def test_page_size_in_request(self):
+        """
+        Tests that the correct page_size is added to requests when loading subsequent pages
+        """
+
+        for i in (25, 100, 500):
+            # these are the pages we're sending in to the mocked list
+            first_page = [ TestModel()  for x in range(i) ]
+            second_page = {
+                "data": [{"id": 1}],
+                "pages": 2,
+                "page": 2,
+                "results": i + 1,
+            }
+
+            # our mock client to intercept the requests and return the mocked info
+            client = MagicMock()
+            client.get = MagicMock(return_value=second_page)
+
+            # let's do it!
+            p = PaginatedList(client, "/test", page=first_page, max_pages=2, total_items=i+1)
+            p[i] # load second page
+
+            # and we called the next page URL with the correct page_size
+            assert client.get.call_args == call("//test?page=2&page_size={}".format(i), filters=None)
+
+    def test_no_pages(self):
+        """
+        Tests that this library correctly handles paginated lists with no data, such
+        as if a paginated endpoint is given a filter that matches nothing.
+        """
+        client = MagicMock()
+
+        p = PaginatedList(client, "/test", page=[], max_pages=0, total_items=0)
+
+        assert(len(p) == 0)