Repository: ucfopen/canvasapi (language: python)

Issue:
Update Canvas.get_todo_items to use Todo class
In #426, we created a new `Todo` class to wrap TODO items. This was so the newly-added `Course.get_todo_items()` functions properly with our current implementation of `PaginatedList`. As noted in that PR, we need to apply this same treatment to `Canvas.get_todo_items()`.


Discussion / hints:
I can do this @Thetwam.

Fix patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..2178065e
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,24 @@
+name: Deploy to PyPI
+
+on:
+  push:
+    tags:
+      - v[0-9]+.[0-9]+.[0-9]+
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+
+      - name: Set up Python
+        uses: actions/setup-python@v2
+
+      - name: Create source distribution
+        run: python setup.py sdist
+
+      - name: Publish distribution to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          user: __token__
+          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4b2fb8cf..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-language: python
-matrix:
-  include:
-    - python: 3.6
-      dist: bionic
-    - python: 3.7
-      dist: bionic
-      sudo: true
-    - python: 3.8
-      dist: bionic
-      sudo: true
-install:
-  - pip install -r tests_requirements.txt
-  - pip install coveralls
-  - gem install mdl
-script:
-  - coverage run -m unittest discover
-  - black --check canvasapi tests
-  - isort --check canvasapi tests
-  - flake8 canvasapi tests
-  - mdl . .github
-  - python scripts/alphabetic.py
-  - python scripts/find_missing_kwargs.py
-after_success:
-  - coveralls
-notifications:
-  slack:
-    secure: oLrAMXKVN9EKOyvROg5ZZUQgeleKq6lsVslm/Jtg516LtdBnLiAA672xMrwB0m8UtSJvoz4m1BrBQBeO+OzxlmioJHDvm//MMXv8wpfBdi18ojBA/5wjYfYP4eta/9j1x4myWH9ygcJOdKhALOSt4fwKsmIRDt6YTensOaqcCTiS2YjtAWMLrKvlathpdMokx0k7FVluJJ+zEfzNote75AP7yq6nFFe9j0ALX7YuNFytshZXi82aKZpAuUGMNLjcL+B5acLKSYqF214sTVETPa2mrhja+89+W9512X3ecRdHPC6Ud10ICD6ap0XMrOcgNW8XREV0pDIDPrMJgSUFiFWMhvrkMjfnmJB2nrINPTc3CrMmmk8Hk1XYta+iA4yAEUb9dhkKmkxMZWtn7TZ7DiKaAXznwj6poGrnN+NXeWfoJ0j2pAUrcO4Jn9e43+ORMDYqCFV3QemtykbX0HKgVfOTx5F1ZBGULiMi7U3q5naieGep5j+OxNI9bcQMUoM6O+VxmkjtB1/8/F7PlKHrA/7Z1+s+1HM00lSvw5qAhkInPgkLU+X495+TsEfqJuVZIUIAS1jDDSuo3bTHHUYzDwE9UF/dYN/nVDiUEVa4xYe+UrAFuPnezL/bdezVwzoGd1Mb5HKJxs90wxYIZFkReivA1z4bQsYh+A/QbYKewR0=
-deploy:
-  provider: pypi
-  username: "__token__"
-  password:
-    secure: kO9KrFtczXgO/u88YpNJDqincHYHKoj8sljkCheLuVLjGnYzhakUbovCL7zzmhnx8JIicpD7j+oa1rbFidNQa8GJ32dwHnNl22126OL6IdfNyLisRPlWlGCyJ3X/h8KGRWhglESBweKDWpVj1QTVJpfn5xWv953tKovcxmudO8lTIDZyIzFLRdVcnOsmSTwkslTmILBufs8p195fso4xOmwGirWBefbgmSUZ6Oxbg3kEu3NXUuh9n+egwhFCTS9m6sl5e9H+a+Oq8nhfbpI0BB/CpBBhpkqMsewmK+CVPNtbj3f3bUnnZC2czCc74QeH2cQr/iCR9Y0JbyM8WY+/0lBTeG5I/4k+ZPgiSiv8g23QvPLcMe297DTBldONEFBKgDFcIzAA3tQ8JXjLXLp+NXosFhMKZcFLXg43TGx1X2nVgcPPvTsxgJXAKOK2VydF8ZAS0v/9nowEZ1yS1qDNpFJhBWyKdTzDXeZ2xruK9OOUHKDFdF/jPqnVkPSRRQjT4uIVCLEab7RzzqhZaZRIf/2gTHfErjVlb2cv5JxlsUt1RJB8JmbkYkd01K48RR8ZJ72ARh3P4x2iYfXXkgXOLJYiY3CW5Uqw/dAqpvJv8mt49UIa9J2nVsxEzOvrqpYFVZf3e/BTBm7p8mCT4MKxCSiglzV6LgS+ZMS7uvCViY4=
-  on:
-    tags: true
-  skip_existing: true
diff --git a/AUTHORS.md b/AUTHORS.md
index a20241a8..8b36f70f 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -10,6 +10,7 @@ Development Lead
 Patches and Suggestions
 -----------------------
 
+- Abrahan Nevarez [@zenith110](https://github.com/zenith110)
 - Adrian Goetz [@a-goetz](https://github.com/a-goetz)
 - Aileen Pongnon [@aileenpongnon](https://github.com/aileenpongnon)
 - Alyssa Davis [@allygator](https://github.com/allygator)
@@ -26,10 +27,12 @@ Patches and Suggestions
 - Catherine Abbruzzese [@cat0698](https://github.com/cat0698))
 - Cameron Cuff [@ctcuff](https://github.com/ctcuff)
 - Dalton Durst [@UniversalSuperBox](https://github.com/UniversalSuperBox)
+- Damian Sweeney [@damianfs](https://github.com/damianfs)
 - Daniel Brinkman [@DanBrink91](https://github.com/DanBrink91)
 - Daniel Grobani [@dgrobani](https://github.com/dgrobani)
 - David Warden [@dfwarden](https://github.com/dfwarden)
 - Davis Goff [@Goff-Davis](https://github.com/Goff-Davis)
+- Deundre Williams [@deundrewilliams](https://github.com/deundrewilliams)
 - Devin Singh [@devints47](https://github.com/devints47)
 - Dmitry Savransky [@dsavransky](https://github.com/dsavransky)
 - Elise Heron [@thedarkestknight](https://github.com/thedarkestknight)
@@ -42,10 +45,12 @@ Patches and Suggestions
 - Ian Altgilbers [@altgilbers](https://github.com/altgilbers)
 - Ian Turgeon [@iturgeon](https://github.com/iturgeon)
 - [@jackrsteiner](https://github.com/jackrsteiner)
+- John Raible [@rebelaide](https://github.com/rebelaide)
 - Joon Ro [@joonro](https://github.com/joonro)
+- Jonah Majumder [@jonahmajumder](https://github.com/jonahmajumder)
 - Jonathan Guilbe [@JonGuilbe](https://github.com/JonGuilbe)
-- John Raible [@rebelaide](https://github.com/rebelaide)
 - Jose Silveti [@jrsilveti](https://github.com/jrsilveti)
+- Joshua Salzedo [@theunkn0wn1](https://github.com/theunkn0wn1)
 - Keegan Berry [@keeeeeegan](https://github.com/keeeeeegan)
 - Kenny Perez [@kennygperez](https://github.com/kennygperez)
 - [@kensler](https://github.com/kensler)
@@ -77,3 +82,4 @@ Patches and Suggestions
 - William Funk [@WilliamRADFunk](https://github.com/WilliamRADFunk)
 - William Wesley Monroe [@purpleHey](https://github.com/purpleHey)
 - [@wjw27](https://github.com/wjw27)
+- Yehuda Davis [@hudcap](https://github.com/hudcap)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5fc59c73..744f7397 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,29 @@
 
 ## [Unreleased]
 
+## [2.2.0] - 2021-03-25
+
+### New Endpoint Coverage
+
+- Enroll a user in a section (Thanks, [@damianfs](https://github.com/damianfs))
+- File quota for courses, groups, and users (Thanks, [@deundrewilliams](https://github.com/deundrewilliams))
+- Provisional Grades (Thanks, [@zenith110](https://github.com/zenith110))
+
+### General
+
+- Added support for Python 3.9
+- Added `RateLimitExceeded` exception to distinguish between being rate limited and being otherwise forbidden from accesing a resource. It is a subclass of the `Forbidden` exception.
+- File uploads now accept path-like objects (Thanks, [@theunkn0wn1](https://github.com/theunkn0wn1))
+- Add list of CanvasAPI Projects to README (Thanks, [@deundrewilliams](https://github.com/deundrewilliams))
+- PyPI Package Description now uses README (Thanks, [@bennettscience](https://github.com/bennettscience))
+- Replaced Travis CI with GitHub Actions
+
+### Bugfixes
+
+- Fixed an issue where `Canvas.create_poll()` did not work due to an incorrect parameter.
+- Canvas.get_todo_items() now correctly returns a `PaginatedList` of `Todo` items (Thanks, [@bennettscience](https://github.com/bennettscience))
+- Fixed an issue where `Favorite.remove()` did not handle parameters properly. (Thanks, [@deundrewilliams](https://github.com/deundrewilliams))
+
 ## [2.1.0] - 2020-12-04
 
 ### New Endpoint Coverage
@@ -511,7 +534,8 @@ Huge thanks to [@liblit](https://github.com/liblit) for lots of issues, suggesti
 - Fixed some incorrectly defined parameters
 - Fixed an issue where tests would fail due to an improperly configured requires block
 
-[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v2.1.0...develop
+[Unreleased]: https://github.com/ucfopen/canvasapi/compare/v2.2.0...develop
+[2.2.0]: https://github.com/ucfopen/canvasapi/compare/v2.1.0...v2.2.0
 [2.1.0]: https://github.com/ucfopen/canvasapi/compare/v2.0.0...v2.1.0
 [2.0.0]: https://github.com/ucfopen/canvasapi/compare/v1.0.0...v2.0.0
 [1.0.0]: https://github.com/ucfopen/canvasapi/compare/v0.16.1...v1.0.0
diff --git a/DEPLOY.md b/DEPLOY.md
index bd9e9657..254094ea 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -28,6 +28,6 @@ Tag the merge commit with the version number: `git tag -s v0.0.0  -m "Release ve
 
 Push the tag: `git push upstream v0.0.0`
 
-Travis should automatically deploy the tagged code to PyPI.
+GitHub Actions should automatically deploy the tagged code to PyPI.
 
 Create release on GitHub for the new tag. Use the text from the changelog for content.
diff --git a/README.md b/README.md
index ed36af6b..274560ff 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ CanvasAPI is a Python library for accessing Instructure’s [Canvas LMS API](htt
             * [User objects](#user-objects)
             * [Paginated Lists](#paginated-lists)
             * [Keyword arguments](#keyword-arguments)
+    * [CanvasAPI Projects](#canvasapi-projects)
     * [Contact Us](#contact-us)
 
 ## Installation
@@ -147,14 +148,23 @@ Most of Canvas’s API endpoints accept a variety of arguments. CanvasAPI allows
 ```python
 # Get all of the active courses a user is currently enrolled in
 >>> courses = user.get_courses(enrollment_state='active')
-
-
-# Fetch 50 objects per page when making calls that return a PaginatedList
->>> courses = user.get_courses(per_page=50)
 ```
 
 For a more detailed description of how CanvasAPI handles more complex keyword arguments, check out the [Keyword Argument Documentation](http://canvasapi.readthedocs.io/en/stable/keyword-args.html).
 
+## CanvasAPI Projects
+
+Since its initial release in June 2016, CanvasAPI has amassed over 100 [dependent repositories](https://github.com/ucfopen/canvasapi/network/dependents). Many of these include various tools used to enhance the Canvas experience for both instructors and students. Here are a few popular repositories that use CanvasAPI:
+
+* [Canvas Grab](https://github.com/skyzh/canvas_grab)
+    * Canvas Grab is the most popular project using CanvasAPI. This tool, with one click, copies all files from Canvas LMS to local directory. CanvasAPI is used in this project to connect to a course and grab its files.
+* [Clanvas](https://github.com/marklalor/clanvas)
+    * Clanvas is a command-line client for Canvas. It uses the already available bash commands plus some additional ones to interact with various features of Canvas from the commmand line.
+* [CS221Bot](https://github.com/Person314159/cs221bot)
+    * CS221Bot is a Discord bot for the CPCS 221 course at University of British Columbia. CanvasAPI is used in this project to connect to and synchronize with a course and get its data, such as announcements, new assignments, and more.
+
+If you have a project that uses CanvasAPI that you'd like to promote, please contact us!
+
 ## Contact Us
 
-Need help? Have an idea? Just want to say hi? Come join us on the [UCF Open Slack Channel](https://ucf-open-slackin.herokuapp.com) and join the `#canvasapi` channel!
+Need help? Have an idea? Feel free to check out our [Discussions](https://github.com/ucfopen/canvasapi/discussions) board. Just want to say hi or get extended spport? Come join us on the [UCF Open Slack Channel](https://ucf-open-slackin.herokuapp.com) and join the `#canvasapi` channel!
diff --git a/canvasapi/__init__.py b/canvasapi/__init__.py
index 2d3cf913..40a49573 100644
--- a/canvasapi/__init__.py
+++ b/canvasapi/__init__.py
@@ -4,4 +4,4 @@
 
 __all__ = ["Canvas"]
 
-__version__ = "2.1.0"
+__version__ = "2.2.0"
diff --git a/canvasapi/assignment.py b/canvasapi/assignment.py
index d9874769..4b015668 100644
--- a/canvasapi/assignment.py
+++ b/canvasapi/assignment.py
@@ -4,7 +4,7 @@
 from canvasapi.peer_review import PeerReview
 from canvasapi.progress import Progress
 from canvasapi.submission import Submission
-from canvasapi.upload import Uploader
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.user import User, UserDisplay
 from canvasapi.util import combine_kwargs, obj_or_id
 
@@ -160,6 +160,32 @@ def get_peer_reviews(self, **kwargs):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def get_provisional_grades_status(self, student_id, **kwargs):
+        """
+        Tell whether the student's submission needs one or more provisional grades.
+
+        :calls: `GET /api/v1/courses/:course_id/assignments/:assignment_id/provisional_grades/
+            status \
+        <https://canvas.instructure.com/doc/api/all_resources.html#method.provisional_grades.status>`_
+
+        :param student_id: The object or ID of the related student
+        :type student_id: :class:`canvasapi.user.User` or int
+
+        :rtype: bool
+        """
+        kwargs["student_id"] = obj_or_id(student_id, "student_id", (User,))
+        request = self._requester.request(
+            "GET",
+            "courses/{}/assignments/{}/provisional_grades/status".format(
+                self.course_id, self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        request_json = request.json()
+
+        return request_json.get("needs_provisional_grade")
+
     def get_submission(self, user, **kwargs):
         """
         Get a single submission, based on user id.
@@ -205,6 +231,55 @@ def get_submissions(self, **kwargs):
             _kwargs=combine_kwargs(**kwargs),
         )
 
+    def publish_provisional_grades(self, **kwargs):
+        """
+        Publish the selected provisional grade for all submissions to an assignment.
+        Use the “Select provisional grade” endpoint to choose which provisional grade to publish
+        for a particular submission.
+
+        Students not in the moderation set will have their one
+        and only provisional grade published.
+
+        WARNING: This is irreversible. This will overwrite existing grades in the gradebook.
+
+        :calls: `POST /api/v1/courses/:course_id/assignments/:assignment_id/provisional_grades
+            /publish \
+        <https://canvas.instructure.com/doc/api/all_resources.html#method.provisional_grades.publish>`_
+        :rtype: dict
+        """
+        response = self._requester.request(
+            "POST",
+            "courses/{}/assignments/{}/provisional_grades/publish".format(
+                self.course_id, self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+        return response.json()
+
+    def selected_provisional_grade(self, provisional_grade_id, **kwargs):
+        """
+        Choose which provisional grade the student should receive for a submission.
+        The caller must be the final grader for the assignment
+        or an admin with :select_final_grade rights.
+
+        :calls: `PUT /api/v1/courses/:course_id/assignments/:assignment_id/provisional_grades/
+            :provisonal_grade_id/select \
+        <https://canvas.instructure.com/doc/api/all_resources.html#method.provisional_grades.select>`_
+
+        :param provisional_grade_id: ID of the provisional grade
+        :type provisional_grade_id: int
+        :rtype: dict
+        """
+        response = self._requester.request(
+            "PUT",
+            "courses/{}/assignments/{}/provisional_grades/{}/select".format(
+                self.course_id, self.id, provisional_grade_id
+            ),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return response.json()
+
     def set_extensions(self, assignment_extensions, **kwargs):
         """
         Set extensions for student assignment submissions
@@ -222,11 +297,11 @@ def set_extensions(self, assignment_extensions, **kwargs):
         >>> assignment.set_extensions([
         ...     {
         ...         'user_id': 3,
-        ...         'extra_attempts: 2
+        ...         'extra_attempts': 2
         ...     },
         ...     {
         ...         'user_id': 2,
-        ...         'extra_attempts: 2
+        ...         'extra_attempts': 2
         ...     }
         ... ])
         """
@@ -254,6 +329,30 @@ def set_extensions(self, assignment_extensions, **kwargs):
             for extension in extension_list
         ]
 
+    def show_provisonal_grades_for_student(self, anonymous_id, **kwargs):
+        """
+        :call: `GET /api/v1/courses/:course_id/assignments/:assignment_id/
+            anonymous_provisional_grades/status \
+        <https://canvas.instructure.com/doc/api/all_resources.html#method.anonymous_provisional_grades.status>`_
+
+        :param anonymous_id: The ID of the student to show the status for
+        :type anonymous_id: :class:`canvasapi.user.User` or int
+
+        :rtype: dict
+        """
+
+        kwargs["anonymous_id"] = obj_or_id(anonymous_id, "anonymous_id", (User,))
+
+        request = self._requester.request(
+            "GET",
+            "courses/{}/assignments/{}/anonymous_provisional_grades/status".format(
+                self.course_id, self.id
+            ),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return request.json().get("needs_provisional_grade")
+
     def submissions_bulk_update(self, **kwargs):
         """
         Update the grading and comments on multiple student's assignment
@@ -318,7 +417,7 @@ def submit(self, submission, file=None, **kwargs):
 
         return Submission(self._requester, response_json)
 
-    def upload_to_submission(self, file, user="self", **kwargs):
+    def upload_to_submission(self, file: FileOrPathLike, user="self", **kwargs):
         """
         Upload a file to a submission.
 
@@ -327,7 +426,7 @@ def upload_to_submission(self, file, user="self", **kwargs):
         <https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.create_file>`_
 
         :param file: The file or path of the file to upload.
-        :type file: file or str
+        :type file: FileLike
         :param user: The object or ID of the related user, or 'self' for the
             current user. Defaults to 'self'.
         :type user: :class:`canvasapi.user.User`, int, or str
diff --git a/canvasapi/canvas.py b/canvasapi/canvas.py
index 962106e3..51b632ca 100644
--- a/canvasapi/canvas.py
+++ b/canvasapi/canvas.py
@@ -12,6 +12,7 @@
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.requester import Requester
 from canvasapi.section import Section
+from canvasapi.todo import Todo
 from canvasapi.user import User
 from canvasapi.util import combine_kwargs, get_institution_url, obj_or_id
 
@@ -343,28 +344,28 @@ def create_planner_override(self, plannable_type, plannable_id, **kwargs):
         )
         return PlannerOverride(self.__requester, response.json())
 
-    def create_poll(self, poll, **kwargs):
+    def create_poll(self, polls, **kwargs):
         """
         Create a new poll for the current user.
 
         :calls: `POST /api/v1/polls \
         <https://canvas.instructure.com/doc/api/polls.html#method.polling/polls.create>`_
 
-        :param poll: List of polls to create. `'question'` key is required.
-        :type poll: list of dict
+        :param polls: List of polls to create. `'question'` key is required.
+        :type polls: list of dict
         :rtype: :class:`canvasapi.poll.Poll`
         """
         from canvasapi.poll import Poll
 
         if (
-            isinstance(poll, list)
-            and isinstance(poll[0], dict)
-            and "question" in poll[0]
+            isinstance(polls, list)
+            and isinstance(polls[0], dict)
+            and "question" in polls[0]
         ):
-            kwargs["poll"] = poll
+            kwargs["polls"] = polls
         else:
             raise RequiredFieldMissing(
-                "Dictionary with key 'question' and is required."
+                "List of dictionaries each with key 'question' is required."
             )
 
         response = self.__requester.request(
@@ -1170,10 +1171,13 @@ def get_todo_items(self, **kwargs):
 
         :rtype: dict
         """
-        response = self.__requester.request(
-            "GET", "users/self/todo", _kwargs=combine_kwargs(**kwargs)
+        return PaginatedList(
+            Todo,
+            self.__requester,
+            "GET",
+            "users/self/todo",
+            _kwargs=combine_kwargs(**kwargs),
         )
-        return response.json()
 
     def get_upcoming_events(self, **kwargs):
         """
diff --git a/canvasapi/course.py b/canvasapi/course.py
index 36aac1d1..fcb97f05 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -28,7 +28,7 @@
 from canvasapi.submission import GroupedSubmission, Submission
 from canvasapi.tab import Tab
 from canvasapi.todo import Todo
-from canvasapi.upload import Uploader
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.usage_rights import UsageRights
 from canvasapi.util import (
     combine_kwargs,
@@ -1312,6 +1312,24 @@ def get_file(self, file, **kwargs):
         )
         return File(self._requester, response.json())
 
+    def get_file_quota(self, **kwargs):
+        """
+        Returns the total and used storage quota for the course.
+
+        :calls: `GET /api/v1/courses/:course_id/files/quota \
+        <https://canvas.instructure.com/doc/api/files.html#method.files.api_quota>`_
+
+        :rtype: dict
+        """
+
+        response = self._requester.request(
+            "GET",
+            "courses/{}/files/quota".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return response.json()
+
     def get_files(self, **kwargs):
         """
         Returns the paginated list of files for the course.
@@ -1794,7 +1812,7 @@ def get_page(self, url, **kwargs):
         :param url: The url for the page.
         :type url: str
         :returns: The specified page.
-        :rtype: :class:`canvasapi.course.Course`
+        :rtype: :class:`canvasapi.page.Page`
         """
 
         response = self._requester.request(
@@ -1815,7 +1833,7 @@ def get_pages(self, **kwargs):
         <https://canvas.instructure.com/doc/api/pages.html#method.wiki_pages_api.index>`_
 
         :rtype: :class:`canvasapi.paginated_list.PaginatedList` of
-            :class:`canvasapi.course.Course`
+            :class:`canvasapi.page.Page`
         """
         return PaginatedList(
             Page,
@@ -2603,7 +2621,7 @@ def update_settings(self, **kwargs):
         )
         return response.json()
 
-    def upload(self, file, **kwargs):
+    def upload(self, file: FileOrPathLike, **kwargs):
         """
         Upload a file to this course.
 
diff --git a/canvasapi/enrollment.py b/canvasapi/enrollment.py
index 8cef9864..476c8f7c 100644
--- a/canvasapi/enrollment.py
+++ b/canvasapi/enrollment.py
@@ -1,11 +1,12 @@
 from canvasapi.canvas_object import CanvasObject
+from canvasapi.util import combine_kwargs
 
 
 class Enrollment(CanvasObject):
     def __str__(self):
         return "{} ({})".format(self.type, self.id)
 
-    def deactivate(self, task):
+    def deactivate(self, task, **kwargs):
         """
         Delete, conclude, or deactivate an enrollment.
 
@@ -28,14 +29,16 @@ def deactivate(self, task):
                 )
             )
 
+        kwargs["task"] = task
+
         response = self._requester.request(
             "DELETE",
             "courses/{}/enrollments/{}".format(self.course_id, self.id),
-            task=task,
+            _kwargs=combine_kwargs(**kwargs),
         )
         return Enrollment(self._requester, response.json())
 
-    def reactivate(self):
+    def reactivate(self, **kwargs):
         """
         Activate an inactive enrollment.
 
@@ -47,5 +50,6 @@ def reactivate(self):
         response = self._requester.request(
             "PUT",
             "courses/{}/enrollments/{}/reactivate".format(self.course_id, self.id),
+            _kwargs=combine_kwargs(**kwargs),
         )
         return Enrollment(self._requester, response.json())
diff --git a/canvasapi/exceptions.py b/canvasapi/exceptions.py
index 3e4d22f8..2ab31a91 100644
--- a/canvasapi/exceptions.py
+++ b/canvasapi/exceptions.py
@@ -55,6 +55,15 @@ class Forbidden(CanvasException):
     pass
 
 
+class RateLimitExceeded(Forbidden):
+    """
+    Canvas has recieved to many requests from this access token and is
+    throttling this request. Try again later.
+    """
+
+    pass
+
+
 class Conflict(CanvasException):
     """Canvas had a conflict with an existing resource."""
 
diff --git a/canvasapi/favorite.py b/canvasapi/favorite.py
index a436ca72..0eefab73 100644
--- a/canvasapi/favorite.py
+++ b/canvasapi/favorite.py
@@ -17,11 +17,11 @@ def remove(self, **kwargs):
 
         :rtype: :class:`canvasapi.favorite.Favorite`
         """
-        if self.context_type == "course":
+        if self.context_type.lower() == "course":
             id = self.context_id
             uri_str = "users/self/favorites/courses/{}"
 
-        elif self.context_type == "group":
+        elif self.context_type.lower() == "group":
             id = self.context_id
             uri_str = "users/self/favorites/groups/{}"
 
diff --git a/canvasapi/folder.py b/canvasapi/folder.py
index 0b27083b..8ee6b36f 100644
--- a/canvasapi/folder.py
+++ b/canvasapi/folder.py
@@ -1,6 +1,6 @@
 from canvasapi.canvas_object import CanvasObject
 from canvasapi.paginated_list import PaginatedList
-from canvasapi.upload import Uploader
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.util import combine_kwargs, obj_or_id
 
 
@@ -119,7 +119,7 @@ def update(self, **kwargs):
 
         return Folder(self._requester, response.json())
 
-    def upload(self, file, **kwargs):
+    def upload(self, file: FileOrPathLike, **kwargs):
         """
         Upload a file to this folder.
 
diff --git a/canvasapi/group.py b/canvasapi/group.py
index a2287487..1acb6666 100644
--- a/canvasapi/group.py
+++ b/canvasapi/group.py
@@ -6,6 +6,7 @@
 from canvasapi.license import License
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.tab import Tab
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.usage_rights import UsageRights
 from canvasapi.util import combine_kwargs, is_multivalued, obj_or_id
 
@@ -493,6 +494,24 @@ def get_file(self, file, **kwargs):
         )
         return File(self._requester, response.json())
 
+    def get_file_quota(self, **kwargs):
+        """
+        Returns the total and used storage quota for the group.
+
+        :calls: `GET /api/v1/groups/:group_id/files/quota \
+        <https://canvas.instructure.com/doc/api/files.html#method.files.api_quota>`_
+
+        :rtype: dict
+        """
+
+        response = self._requester.request(
+            "GET",
+            "groups/{}/files/quota".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return response.json()
+
     def get_files(self, **kwargs):
         """
         Returns the paginated list of files for the group.
@@ -948,7 +967,7 @@ def update_membership(self, user, **kwargs):
         )
         return GroupMembership(self._requester, response.json())
 
-    def upload(self, file, **kwargs):
+    def upload(self, file: FileOrPathLike, **kwargs):
         """
         Upload a file to the group.
         Only those with the 'Manage Files' permission on a group can upload files to the group.
@@ -965,7 +984,6 @@ def upload(self, file, **kwargs):
                     and the JSON response from the API.
         :rtype: tuple
         """
-        from canvasapi.upload import Uploader
 
         return Uploader(
             self._requester, "groups/{}/files".format(self.id), file, **kwargs
diff --git a/canvasapi/requester.py b/canvasapi/requester.py
index 2d236ce2..b1b87450 100644
--- a/canvasapi/requester.py
+++ b/canvasapi/requester.py
@@ -10,6 +10,7 @@
     Conflict,
     Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -241,7 +242,15 @@ def request(
             else:
                 raise Unauthorized(response.json())
         elif response.status_code == 403:
-            raise Forbidden(response.text)
+            if b"Rate Limit Exceeded" in response.content:
+                remaining = str(
+                    response.headers.get("X-Rate-Limit-Remaining", "Unknown")
+                )
+                raise RateLimitExceeded(
+                    "Rate Limit Exceeded. X-Rate-Limit-Remaining: {}".format(remaining)
+                )
+            else:
+                raise Forbidden(response.text)
         elif response.status_code == 404:
             raise ResourceDoesNotExist("Not Found")
         elif response.status_code == 409:
diff --git a/canvasapi/section.py b/canvasapi/section.py
index 05fdb4f5..8046b648 100644
--- a/canvasapi/section.py
+++ b/canvasapi/section.py
@@ -1,7 +1,9 @@
 from canvasapi.canvas_object import CanvasObject
+from canvasapi.enrollment import Enrollment
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.progress import Progress
 from canvasapi.submission import GroupedSubmission, Submission
+from canvasapi.user import User
 from canvasapi.util import combine_kwargs, normalize_bool, obj_or_id
 
 
@@ -80,6 +82,28 @@ def edit(self, **kwargs):
 
         return self
 
+    def enroll_user(self, user, **kwargs):
+        """
+        Create a new user enrollment for a course or a section.
+
+        :calls: `POST /api/v1/section/:section_id/enrollments \
+        <https://canvas.instructure.com/doc/api/enrollments.html#method.enrollments_api.create>`_
+
+        :param user: The object or ID of the user to enroll in this course.
+        :type user: :class:`canvasapi.user.User` or int
+        :rtype: :class:`canvasapi.enrollment.Enrollment`
+        """
+
+        kwargs["enrollment[user_id]"] = obj_or_id(user, "user", (User,))
+
+        response = self._requester.request(
+            "POST",
+            "sections/{}/enrollments".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return Enrollment(self._requester, response.json())
+
     def get_assignment_override(self, assignment, **kwargs):
         """
         Return override for the specified assignment for this section.
diff --git a/canvasapi/submission.py b/canvasapi/submission.py
index 390789cf..6e1250e0 100644
--- a/canvasapi/submission.py
+++ b/canvasapi/submission.py
@@ -1,7 +1,7 @@
 from canvasapi.canvas_object import CanvasObject
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.peer_review import PeerReview
-from canvasapi.upload import Uploader
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.util import combine_kwargs, obj_or_id
 
 
@@ -79,6 +79,7 @@ def edit(self, **kwargs):
             _kwargs=combine_kwargs(**kwargs),
         )
         response_json = response.json()
+
         response_json.update(course_id=self.course_id)
 
         super(Submission, self).set_attributes(response_json)
@@ -143,7 +144,7 @@ def mark_unread(self, **kwargs):
         )
         return response.status_code == 204
 
-    def upload_comment(self, file, **kwargs):
+    def upload_comment(self, file: FileOrPathLike, **kwargs):
         """
         Upload a file to attach to this submission as a comment.
 
diff --git a/canvasapi/upload.py b/canvasapi/upload.py
index e0a68fc7..3767cc81 100644
--- a/canvasapi/upload.py
+++ b/canvasapi/upload.py
@@ -1,26 +1,33 @@
+import io
 import json
 import os
+from typing import Union
 
 from canvasapi.util import combine_kwargs
 
+FileOrPathLike = Union[os.PathLike, str, io.IOBase, io.FileIO]
+"""
+A path or file-like object. May be either a :class:`os.PathLike`, a `str`, or a file-like object
+"""
+
 
 class Uploader(object):
     """
     Upload a file to Canvas.
     """
 
-    def __init__(self, requester, url, file, **kwargs):
+    def __init__(self, requester, url, file: FileOrPathLike, **kwargs):
         """
         :param requester: The :class:`canvasapi.requester.Requester` to pass requests through.
         :type requester: :class:`canvasapi.requester.Requester`
         :param url: The URL to upload the file to.
         :type url: str
         :param file: A file handler or path of the file to upload.
-        :type file: file or str
+        :type file: :class:`os.PathLike` or str
         """
-        if isinstance(file, str):
+        if isinstance(file, (os.PathLike, str)):
             if not os.path.exists(file):
-                raise IOError("File " + file + " does not exist.")
+                raise IOError("File {} does not exist.".format(os.fspath(file)))
             self._using_filename = True
         else:
             self._using_filename = False
diff --git a/canvasapi/user.py b/canvasapi/user.py
index e7612aaa..74c50791 100644
--- a/canvasapi/user.py
+++ b/canvasapi/user.py
@@ -6,7 +6,7 @@
 from canvasapi.license import License
 from canvasapi.paginated_list import PaginatedList
 from canvasapi.pairing_code import PairingCode
-from canvasapi.upload import Uploader
+from canvasapi.upload import FileOrPathLike, Uploader
 from canvasapi.usage_rights import UsageRights
 from canvasapi.util import combine_kwargs, obj_or_id, obj_or_str
 
@@ -552,6 +552,24 @@ def get_file(self, file, **kwargs):
         )
         return File(self._requester, response.json())
 
+    def get_file_quota(self, **kwargs):
+        """
+        Returns the total and used storage quota for the user.
+
+        :calls: `GET /api/v1/users/:user_id/files/quota \
+        <https://canvas.instructure.com/doc/api/files.html#method.files.api_quota>`_
+
+        :rtype: dict
+        """
+
+        response = self._requester.request(
+            "GET",
+            "users/{}/files/quota".format(self.id),
+            _kwargs=combine_kwargs(**kwargs),
+        )
+
+        return response.json()
+
     def get_files(self, **kwargs):
         """
         Returns the paginated list of files for the user.
@@ -936,7 +954,7 @@ def update_settings(self, **kwargs):
         )
         return response.json()
 
-    def upload(self, file, **kwargs):
+    def upload(self, file: FileOrPathLike, **kwargs):
         """
         Upload a file for a user.
 
diff --git a/docs/exceptions.rst b/docs/exceptions.rst
index cc3419b2..4e26ae6c 100644
--- a/docs/exceptions.rst
+++ b/docs/exceptions.rst
@@ -17,6 +17,8 @@ Quick Guide
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.Forbidden`            | 403             | Canvas has denied access to the resource for this user.                         |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
+| :class:`~canvasapi.exceptions.RateLimitExceeded`    | 403             | Canvas is throttling this request. Try again later.                             |
++-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.ResourceDoesNotExist` | 404             | Canvas could not locate the requested resource.                                 |
 +-----------------------------------------------------+-----------------+---------------------------------------------------------------------------------+
 | :class:`~canvasapi.exceptions.Conflict`             | 409             | Canvas had a conflict with an existing resource.                                |
@@ -81,7 +83,17 @@ Class Reference
 
     The :class:`~canvasapi.exceptions.Forbidden` exception is thrown when Canvas returns an HTTP 403 error.
 
+.. autoclass:: canvasapi.exceptions.RateLimitExceeded
+    :members:
+
+    The :class:`~canvasapi.exceptions.RateLimitExceeded` exception is thrown when Canvas returns an HTTP 403 error that includes the body "403 Forbidden (Rate Limit Exceeded)". It will include the value of the ``X-Rate-Limit-Remaining`` header (if available) for reference.
+
 .. autoclass:: canvasapi.exceptions.Conflict
     :members:
 
     The :class:`~canvasapi.exceptions.Conflict` exception is thrown when Canvas returns an HTTP 409 error.
+
+.. autoclass:: canvasapi.exceptions.UnprocessableEntity
+    :members:
+
+    The :class:`~canvasapi.exceptions.UnprocessableEntity` exception is thrown when Canvas returns an HTTP 422 error.
diff --git a/docs/getting-started.rst b/docs/getting-started.rst
index 5fa85019..af403bb2 100644
--- a/docs/getting-started.rst
+++ b/docs/getting-started.rst
@@ -117,9 +117,6 @@ Most of Canvas’s API endpoints accept a variety of arguments. CanvasAPI allows
     # Get all of the active courses a user is currently enrolled in
     >>> courses = user.get_courses(enrollment_status='active')
 
-    # Fetch 50 objects per page when making calls that return a PaginatedList
-    >>> courses = user.get_courses(per_page=50)
-
 For a more detailed description of how CanvasAPI handles more complex keyword arguments, check out the `Keyword Argument Documentation <keyword-args.html>`_.
 
 Smart DateTimes
diff --git a/scripts/find_missing_kwargs.py b/scripts/find_missing_kwargs.py
index 41325943..92969a8b 100644
--- a/scripts/find_missing_kwargs.py
+++ b/scripts/find_missing_kwargs.py
@@ -1,5 +1,4 @@
 import inspect
-import operator
 import os
 import sys
 
diff --git a/setup.cfg b/setup.cfg
index d690360f..e65387d1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,6 +3,9 @@ source = canvasapi/
 
 [flake8]
 max-line-length = 99
+exclude = .git,__pycache__,docs/conf.py,old,build,dist
+statistics = True
+show_source = True
 
 [isort]
 profile=black
diff --git a/setup.py b/setup.py
index 1a2e7f15..212542bc 100644
--- a/setup.py
+++ b/setup.py
@@ -1,40 +1,48 @@
 import re
+from os import path
+
 from setuptools import setup
 
 # get version number
-with open('canvasapi/__init__.py', 'r') as fd:
+with open("canvasapi/__init__.py", "r") as fd:
     version = re.search(
-        r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',
-        fd.read(),
-        re.MULTILINE
+        r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE
     ).group(1)
 
 if not version:
-    raise RuntimeError('Cannot find version information')
+    raise RuntimeError("Cannot find version information")
+
+# Get the PyPI package info from the readme
+this_directory = path.abspath(path.dirname(__file__))
+with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
+    long_description = f.read()
 
 setup(
-    name='canvasapi',
+    name="canvasapi",
     version=version,
-    description='API wrapper for the Canvas LMS',
-    url='https://github.com/ucfopen/canvasapi',
-    author='University of Central Florida - Center for Distributed Learning',
-    author_email='techrangers@ucf.edu',
-    license='MIT License',
-    packages=['canvasapi'],
+    description="API wrapper for the Canvas LMS",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://github.com/ucfopen/canvasapi",
+    author="University of Central Florida - Center for Distributed Learning",
+    author_email="techrangers@ucf.edu",
+    license="MIT License",
+    packages=["canvasapi"],
     include_package_data=True,
-    install_requires=['pytz', 'requests'],
+    install_requires=["pytz", "requests"],
     zip_safe=False,
     classifiers=[
-        'Development Status :: 3 - Alpha',
-        'Intended Audience :: Developers',
-        'Intended Audience :: Education',
-        'Intended Audience :: Information Technology',
-        'License :: OSI Approved :: MIT License',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
-        'Topic :: Software Development :: Libraries',
+        "Development Status :: 5 - Production/Stable",
+        "Intended Audience :: Developers",
+        "Intended Audience :: Education",
+        "Intended Audience :: Information Technology",
+        "License :: OSI Approved :: MIT License",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Topic :: Software Development :: Libraries",
     ],
 )


Test patch:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 00000000..acbd48fb
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,43 @@
+name: Run tests
+
+on:
+  push:
+    branches: [master, develop]
+  pull_request:
+    branches: [master, develop]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.6, 3.7, 3.8, 3.9]
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r tests_requirements.txt
+      - name: Lint with flake8
+        run: flake8
+      - name: Check formatting
+        run: black --check canvasapi tests
+      - name: Check import sorting
+        run: isort --check canvasapi tests
+      - name: Run tests
+        run: coverage run -m unittest discover
+      - name: Lint markdown files
+        uses: bewuethr/mdl-action@v1
+      - name: Check if methods are alphabetical
+        run: python scripts/alphabetic.py
+      - name: Check for missing kwargs
+        run: python scripts/find_missing_kwargs.py
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        with:
+          fail_ci_if_error: true
diff --git a/tests/fixtures/assignment.json b/tests/fixtures/assignment.json
index e457e630..c6f62857 100644
--- a/tests/fixtures/assignment.json
+++ b/tests/fixtures/assignment.json
@@ -379,5 +379,35 @@
 		"data": {
 			"no_url": "bad_url_failure"
 		}
+	},
+	"get_provisional_grades_status": {
+		"method": "GET",
+		"endpoint": "courses/1/assignments/1/provisional_grades/status",
+		"data": {
+			"needs_provisional_grade": false
+		}
+	},
+	"show_provisonal_grades_for_student": {
+		"method": "GET",
+		"endpoint": "courses/1/assignments/1/anonymous_provisional_grades/status",
+		"data": {
+			"needs_provisional_grade": false
+		}
+	},
+	"selected_provisional_grade": {
+		"method": "PUT",
+		"endpoint": "courses/1/assignments/1/provisional_grades/1/select",
+		"data": {
+			"assignment_id": 1,
+			"student_id": 2,
+			"provisional_grade_id": 3
+		}
+	},
+	"publish_provisional_grades": {
+		"method": "POST",
+		"endpoint": "courses/1/assignments/1/provisional_grades/publish",
+		"data": {
+			"message": "OK"
+		}
 	}
 }
diff --git a/tests/fixtures/course.json b/tests/fixtures/course.json
index 32db3247..df2bc190 100644
--- a/tests/fixtures/course.json
+++ b/tests/fixtures/course.json
@@ -1334,7 +1334,16 @@
 			"size": 2048
 		},
 		"status_code": 200
-	},
+    },
+    "get_file_quota": {
+        "method": "GET",
+        "endpoint": "courses/1/files/quota",
+        "data": {
+            "quota": 524288000,
+            "quota_used": 402653184
+        },
+        "status_code": 200
+    },
 	"get_full_discussion_topic": {
 		"method": "GET",
 		"endpoint": "courses/1/discussion_topics/1/view",
@@ -1831,11 +1840,11 @@
 				"date": "2018-08-09",
 				"graders": [
 					{
-						"name": "John Smith", 
-						"id": 49, 
+						"name": "John Smith",
+						"id": 49,
 						"assignments":[
 							{
-								"id": 44, 
+								"id": 44,
 								"description": "<p>homework1</p>"
 							}
 						]
@@ -1846,11 +1855,11 @@
 				"date": "2019-10-24",
 				"graders": [
 					{
-						"name": "Sara Hanks", 
-						"id": 32, 
+						"name": "Sara Hanks",
+						"id": 32,
 						"assignments":[
 							{
-								"id": 19, 
+								"id": 19,
 								"description": "<p>homework2</p>"
 							}
 						]
diff --git a/tests/fixtures/current_user.json b/tests/fixtures/current_user.json
index 7c5b998f..80cf2845 100644
--- a/tests/fixtures/current_user.json
+++ b/tests/fixtures/current_user.json
@@ -8,6 +8,16 @@
 				"context_type": "course"
 			},
 		"status_code": 200
+    },
+    "add_favorite_course_cap_context_type": {
+		"method": "POST",
+		"endpoint": "users/self/favorites/courses/1",
+		"data":
+			{
+				"context_id": 1,
+				"context_type": "Course"
+			},
+		"status_code": 200
 	},
 	"add_favorite_course_by_sis_id": {
 		"method": "POST",
@@ -28,6 +38,16 @@
 				"context_type": "group"
 			},
 		"status_code": 200
+    },
+    "add_favorite_group_cap_context_type": {
+		"method": "POST",
+		"endpoint": "users/self/favorites/groups/1",
+		"data":
+			{
+				"context_id": 1,
+				"context_type": "Group"
+			},
+		"status_code": 200
 	},
 	"add_favorite_group_by_sis_id": {
 		"method": "POST",
@@ -122,6 +142,16 @@
 			"context_type": "course"
 		},
 		"status_code": 200
+    },
+    "remove_favorite_course_cap_context_type": {
+		"method": "DELETE",
+		"endpoint": "users/self/favorites/courses/1",
+		"data":
+		{
+			"context_id": 1,
+			"context_type": "Course"
+		},
+		"status_code": 200
 	},
 	"remove_favorite_group": {
 		"method": "DELETE",
@@ -132,6 +162,16 @@
 			"context_type": "group"
 		},
 		"status_code": 200
+    },
+    "remove_favorite_group_cap_context_type": {
+		"method": "DELETE",
+		"endpoint": "users/self/favorites/groups/1",
+		"data":
+		{
+			"context_id": 1,
+			"context_type": "Group"
+		},
+		"status_code": 200
 	},
 	"reset_favorite_courses": {
 		"method": "DELETE",
diff --git a/tests/fixtures/group.json b/tests/fixtures/group.json
index c115946c..638e1060 100644
--- a/tests/fixtures/group.json
+++ b/tests/fixtures/group.json
@@ -633,7 +633,16 @@
 			"size": 4096
 		},
 		"status_code": 200
-	},
+    },
+    "get_file_quota": {
+        "method": "GET",
+        "endpoint": "groups/1/files/quota",
+        "data": {
+            "quota": 777648912,
+            "quota_used": 567864213
+        },
+        "status_code": 200
+    },
 	"get_full_discussion_topic": {
 		"method": "GET",
 		"endpoint": "groups/1/discussion_topics/1/view",
diff --git a/tests/fixtures/requests.json b/tests/fixtures/requests.json
index ca123fca..1e0c7a5d 100644
--- a/tests/fixtures/requests.json
+++ b/tests/fixtures/requests.json
@@ -20,6 +20,31 @@
 		"data": {},
 		"status_code": 401
 	},
+	"403": {
+		"method": "ANY",
+		"endpoint": "403",
+		"data": {},
+		"status_code": 403
+	},
+	"403_rate_limit": {
+		"method": "ANY",
+		"endpoint": "403_rate_limit",
+		"data": "403 Forbidden (Rate Limit Exceeded)",
+		"headers": {
+			"X-Rate-Limit-Remaining": "3.14159265359",
+			"X-Request-Cost": "1.61803398875"
+		},
+		"status_code": 403
+	},
+	"403_rate_limit_no_remaining_header": {
+		"method": "ANY",
+		"endpoint": "403_rate_limit_no_remaining_header",
+		"data": "403 Forbidden (Rate Limit Exceeded)",
+		"headers": {
+			"X-Request-Cost": "1.61803398875"
+		},
+		"status_code": 403
+	},
 	"404": {
 		"method": "ANY",
 		"endpoint": "404",
diff --git a/tests/fixtures/section.json b/tests/fixtures/section.json
index a4eb9366..82c5cded 100644
--- a/tests/fixtures/section.json
+++ b/tests/fixtures/section.json
@@ -230,5 +230,16 @@
 			"url": "https://canvas.example.edu/api/v1/progress/3"
 		},
 		"status_code": 200
+	},
+	"enroll_user": {
+		"method": "POST",
+		"endpoint": "sections/1/enrollments",
+		"data": {
+			"id": 1,
+			"course_id": 1,
+			"user_id": 1,
+			"type": "TeacherEnrollment"
+		},
+		"status_code": 200
 	}
 }
diff --git a/tests/fixtures/user.json b/tests/fixtures/user.json
index 11b77b18..5c49be59 100644
--- a/tests/fixtures/user.json
+++ b/tests/fixtures/user.json
@@ -243,7 +243,16 @@
 			"size": 1024
 		},
 		"status_code": 200
-	},
+    },
+    "get_file_quota": {
+        "method": "GET",
+        "endpoint": "users/1/files/quota",
+        "data": {
+            "quota": 889234510,
+            "quota_used": 476231098
+        },
+        "status_code": 200
+    },
 	"get_folder": {
         "method": "GET",
         "endpoint": "users/1/folders/1",
diff --git a/tests/test_assignment.py b/tests/test_assignment.py
index e77a3253..b86314b6 100644
--- a/tests/test_assignment.py
+++ b/tests/test_assignment.py
@@ -1,5 +1,6 @@
 import unittest
 import uuid
+from pathlib import Path
 
 import requests_mock
 
@@ -215,6 +216,24 @@ def test_submit_file(self, m):
         finally:
             cleanup_file(filename)
 
+    def test_submit_file_pathlib(self, m):
+        register_uris({"assignment": ["submit", "upload", "upload_final"]}, m)
+
+        filename = Path("testfile_assignment_{}".format(uuid.uuid4().hex))
+        filename.write_bytes(b"test data")
+
+        try:
+            sub_type = "online_upload"
+            sub_dict = {"submission_type": sub_type}
+            submission = self.assignment.submit(sub_dict, filename)
+
+            self.assertIsInstance(submission, Submission)
+            self.assertTrue(hasattr(submission, "submission_type"))
+            self.assertEqual(submission.submission_type, sub_type)
+
+        finally:
+            cleanup_file(filename)
+
     def test_submit_file_wrong_type(self, m):
         filename = "testfile_assignment_{}".format(uuid.uuid4().hex)
         sub_type = "online_text_entry"
@@ -287,6 +306,50 @@ def test_upload_to_submission_user(self, m):
         finally:
             cleanup_file(filename)
 
+    # get_provisional_grades_status
+    def test_get_provisional_grades_status(self, m):
+        register_uris(
+            {"assignment": ["get_provisional_grades_status"], "user": ["get_by_id"]}, m
+        )
+        student_id = 1
+        user = self.canvas.get_user(student_id)
+        status = self.assignment.get_provisional_grades_status(user)
+        self.assertIsInstance(status, bool)
+        self.assertFalse(status)
+
+    # selected_provisional_grade
+    def test_selected_provisional_grade(self, m):
+        register_uris({"assignment": ["selected_provisional_grade"]}, m)
+        provisional_grade_id = 1
+        selected_provisional_grade = self.assignment.selected_provisional_grade(
+            provisional_grade_id
+        )
+        self.assertIsInstance(selected_provisional_grade, dict)
+        self.assertIn("assignment_id", selected_provisional_grade)
+
+    # publish_provisional_grades
+    def test_publish_provisional_grades(self, m):
+        register_uris({"assignment": ["publish_provisional_grades"]}, m)
+        publish = self.assignment.publish_provisional_grades()
+        self.assertIsInstance(publish, dict)
+        self.assertIn("message", publish)
+
+    # show_provisional_grades_for_student
+    def test_show_provisonal_grades_for_student(self, m):
+        register_uris(
+            {
+                "assignment": ["show_provisonal_grades_for_student"],
+                "user": ["get_by_id"],
+            },
+            m,
+        )
+        anonymous_id = 1
+        user = self.canvas.get_user(anonymous_id)
+        show_status = self.assignment.show_provisonal_grades_for_student(user)
+
+        self.assertIsInstance(show_status, bool)
+        self.assertFalse(show_status)
+
 
 @requests_mock.Mocker()
 class TestAssignmentExtension(unittest.TestCase):
diff --git a/tests/test_canvas.py b/tests/test_canvas.py
index 87ec42aa..84be6e24 100644
--- a/tests/test_canvas.py
+++ b/tests/test_canvas.py
@@ -19,8 +19,10 @@
 from canvasapi.group import Group, GroupCategory
 from canvasapi.outcome import Outcome, OutcomeGroup
 from canvasapi.paginated_list import PaginatedList
+from canvasapi.poll import Poll
 from canvasapi.progress import Progress
 from canvasapi.section import Section
+from canvasapi.todo import Todo
 from canvasapi.user import User
 from tests import settings
 from tests.util import register_uris
@@ -92,6 +94,25 @@ def test_create_account(self, m):
         self.assertTrue(hasattr(account, "name"))
         self.assertEqual(account.name, name)
 
+    # create_poll()
+    def test_create_poll(self, m):
+        register_uris({"poll": ["create_poll"]}, m)
+
+        new_poll_q = self.canvas.create_poll([{"question": "Is this a question?"}])
+        self.assertIsInstance(new_poll_q, Poll)
+        self.assertTrue(hasattr(new_poll_q, "question"))
+
+        new_poll_q_d = self.canvas.create_poll(
+            [{"question": "Is this a question?"}, {"description": "This is a test."}]
+        )
+        self.assertIsInstance(new_poll_q_d, Poll)
+        self.assertTrue(hasattr(new_poll_q_d, "question"))
+        self.assertTrue(hasattr(new_poll_q_d, "description"))
+
+    def test_create_poll_fail(self, m):
+        with self.assertRaises(RequiredFieldMissing):
+            self.canvas.create_poll(polls={})
+
     # get_account()
     def test_get_account(self, m):
         register_uris({"account": ["get_by_id"]}, m)
@@ -240,8 +261,9 @@ def test_get_todo_items(self, m):
         register_uris({"user": ["todo_items"]}, m)
 
         todo_items = self.canvas.get_todo_items()
+        todo_list = [todo for todo in todo_items]
 
-        self.assertIsInstance(todo_items, list)
+        self.assertIsInstance(todo_list[0], Todo)
 
     # get_upcoming_events()
     def test_get_upcoming_events(self, m):
diff --git a/tests/test_course.py b/tests/test_course.py
index 2fca3dc6..8f958298 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -766,6 +766,15 @@ def test_get_file(self, m):
         self.assertEqual(file_by_obj.display_name, "Course_File.docx")
         self.assertEqual(file_by_obj.size, 2048)
 
+    # get_file_quota()
+    def test_get_file_quota(self, m):
+        register_uris({"course": ["get_file_quota"]}, m)
+
+        file_quota = self.course.get_file_quota()
+        self.assertIsInstance(file_quota, dict)
+        self.assertEqual(file_quota["quota"], 524288000)
+        self.assertEqual(file_quota["quota_used"], 402653184)
+
     # get_full_discussion_topic()
     def test_get_full_discussion_topic(self, m):
         register_uris(
diff --git a/tests/test_favorite.py b/tests/test_favorite.py
index 7a49114b..6b30759a 100644
--- a/tests/test_favorite.py
+++ b/tests/test_favorite.py
@@ -16,38 +16,133 @@ def setUp(self):
         with requests_mock.Mocker() as m:
             requires = {
                 "current_user": [
-                    "add_favorite_course",
-                    "add_favorite_group",
                     "get_by_id",
                 ]
             }
             register_uris(requires, m)
 
             self.user = self.canvas.get_current_user()
-            self.favorite_course = self.user.add_favorite_course(1)
-            self.favorite_group = self.user.add_favorite_group(1)
 
     # __str__()
-    def test__str__(self, m):
-        string = str(self.favorite_course)
+    def test_uncapitalized___str__(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_course",
+                    "add_favorite_group",
+                ]
+            },
+            m,
+        )
+
+        favorite_course = self.user.add_favorite_course(1)
+        favorite_group = self.user.add_favorite_group(1)
+
+        string = str(favorite_course)
+        self.assertIsInstance(string, str)
+
+        string = str(favorite_group)
+        self.assertIsInstance(string, str)
+
+    def test_capitalized___str__(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_course_cap_context_type",
+                    "add_favorite_group_cap_context_type",
+                ]
+            },
+            m,
+        )
+
+        favorite_course = self.user.add_favorite_course(1)
+        favorite_group = self.user.add_favorite_group(1)
+
+        string = str(favorite_course)
         self.assertIsInstance(string, str)
 
-        string = str(self.favorite_group)
+        string = str(favorite_group)
         self.assertIsInstance(string, str)
 
     # remove()
-    def test_remove_favorite_course(self, m):
-        register_uris({"current_user": ["remove_favorite_course"]}, m)
+    def test_remove_uncapitalized_favorite_course(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_course",
+                    "remove_favorite_course",
+                ]
+            },
+            m,
+        )
+
+        favorite_course = self.user.add_favorite_course(1)
+
+        evnt = favorite_course.remove()
 
-        evnt = self.favorite_course.remove()
         self.assertIsInstance(evnt, Favorite)
         self.assertEqual(evnt.context_type, "course")
         self.assertEqual(evnt.context_id, 1)
 
-    def test_remove_favorite_group(self, m):
-        register_uris({"current_user": ["remove_favorite_group"]}, m)
+    def test_remove_uncapitalized_favorite_group(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_group",
+                    "remove_favorite_group",
+                ]
+            },
+            m,
+        )
+
+        favorite_group = self.user.add_favorite_group(1)
+
+        evnt = favorite_group.remove()
 
-        evnt = self.favorite_group.remove()
         self.assertIsInstance(evnt, Favorite)
         self.assertEqual(evnt.context_type, "group")
         self.assertEqual(evnt.context_id, 1)
+
+    def test_remove_capitalized_favorite_course(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_course_cap_context_type",
+                    "remove_favorite_course_cap_context_type",
+                ]
+            },
+            m,
+        )
+
+        favorite_course = self.user.add_favorite_course(1)
+
+        evnt = favorite_course.remove()
+
+        self.assertIsInstance(evnt, Favorite)
+        self.assertEqual(evnt.context_type, "Course")
+        self.assertEqual(evnt.context_id, 1)
+
+    def test_remove_capitalized_favorite_group(self, m):
+
+        register_uris(
+            {
+                "current_user": [
+                    "add_favorite_group_cap_context_type",
+                    "remove_favorite_group_cap_context_type",
+                ]
+            },
+            m,
+        )
+
+        favorite_group = self.user.add_favorite_group(1)
+
+        evnt = favorite_group.remove()
+
+        self.assertIsInstance(evnt, Favorite)
+        self.assertEqual(evnt.context_type, "Group")
+        self.assertEqual(evnt.context_id, 1)
diff --git a/tests/test_group.py b/tests/test_group.py
index 576be824..f5248d96 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -255,6 +255,15 @@ def test_get_file(self, m):
         self.assertEqual(file_by_obj.display_name, "Group_File.docx")
         self.assertEqual(file_by_obj.size, 4096)
 
+    # get_file_quota()
+    def test_get_file_quota(self, m):
+        register_uris({"group": ["get_file_quota"]}, m)
+
+        file_quota = self.group.get_file_quota()
+        self.assertIsInstance(file_quota, dict)
+        self.assertEqual(file_quota["quota"], 777648912)
+        self.assertEqual(file_quota["quota_used"], 567864213)
+
     # get_full_discussion_topic
     def test_get_full_discussion_topic(self, m):
         register_uris(
diff --git a/tests/test_poll.py b/tests/test_poll.py
index 154bccca..da21776c 100644
--- a/tests/test_poll.py
+++ b/tests/test_poll.py
@@ -48,26 +48,6 @@ def test_get_poll(self, m):
         self.assertEqual(poll_by_obj.description, "This is a test.")
         self.assertEqual(poll_by_obj.created_at, "2014-01-07T13:10:19Z")
 
-    # create_poll()
-    def test_create_poll(self, m):
-        register_uris({"poll": ["create_poll"]}, m)
-
-        new_poll_q = self.canvas.create_poll([{"question": "Is this a question?"}])
-        self.assertIsInstance(new_poll_q, Poll)
-        self.assertTrue(hasattr(new_poll_q, "question"))
-
-        new_poll_q_d = self.canvas.create_poll(
-            [{"question": "Is this a question?"}, {"description": "This is a test."}]
-        )
-        self.assertIsInstance(new_poll_q_d, Poll)
-        self.assertTrue(hasattr(new_poll_q_d, "question"))
-        self.assertTrue(hasattr(new_poll_q_d, "description"))
-
-    # create_poll()
-    def test_create_poll_fail(self, m):
-        with self.assertRaises(RequiredFieldMissing):
-            self.canvas.create_poll(poll={})
-
     # update()
     def test_update(self, m):
         register_uris({"poll": ["update"]}, m)
diff --git a/tests/test_requester.py b/tests/test_requester.py
index df59eb99..f6a45a7d 100644
--- a/tests/test_requester.py
+++ b/tests/test_requester.py
@@ -10,7 +10,9 @@
     BadRequest,
     CanvasException,
     Conflict,
+    Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -145,6 +147,34 @@ def test_request_401_Unauthorized(self, m):
         with self.assertRaises(Unauthorized):
             self.requester.request("GET", "401_unauthorized")
 
+    def test_request_403(self, m):
+        register_uris({"requests": ["403"]}, m)
+
+        with self.assertRaises(Forbidden):
+            self.requester.request("GET", "403")
+
+    def test_request_403_RateLimitExeeded(self, m):
+        register_uris({"requests": ["403_rate_limit"]}, m)
+
+        with self.assertRaises(RateLimitExceeded) as exc:
+            self.requester.request("GET", "403_rate_limit")
+
+        self.assertEqual(
+            exc.exception.message,
+            "Rate Limit Exceeded. X-Rate-Limit-Remaining: 3.14159265359",
+        )
+
+    def test_request_403_RateLimitExeeded_no_remaining_header(self, m):
+        register_uris({"requests": ["403_rate_limit_no_remaining_header"]}, m)
+
+        with self.assertRaises(RateLimitExceeded) as exc:
+            self.requester.request("GET", "403_rate_limit_no_remaining_header")
+
+        self.assertEqual(
+            exc.exception.message,
+            "Rate Limit Exceeded. X-Rate-Limit-Remaining: Unknown",
+        )
+
     def test_request_404(self, m):
         register_uris({"requests": ["404"]}, m)
 
diff --git a/tests/test_section.py b/tests/test_section.py
index ce3f0eed..cc088d18 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -18,9 +18,14 @@ def setUp(self):
         self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
 
         with requests_mock.Mocker() as m:
-            register_uris({"section": ["get_by_id"]}, m)
+            requires = {
+                "section": ["get_by_id"],
+                "user": ["get_by_id"],
+            }
+            register_uris(requires, m)
 
             self.section = self.canvas.get_section(1)
+            self.user = self.canvas.get_user(1)
 
     # __str__()
     def test__str__(self, m):
@@ -121,3 +126,27 @@ def test_submissions_bulk_update(self, m):
         self.assertTrue(progress.context_type == "Course")
         progress = progress.query()
         self.assertTrue(progress.context_type == "Course")
+
+    def test_enroll_user(self, m):
+        requires = {"section": ["enroll_user"], "user": ["get_by_id"]}
+        register_uris(requires, m)
+
+        enrollment_type = "TeacherEnrollment"
+
+        # by user ID
+        enrollment_by_id = self.section.enroll_user(
+            1, enrollment={"type": enrollment_type}
+        )
+
+        self.assertIsInstance(enrollment_by_id, Enrollment)
+        self.assertTrue(hasattr(enrollment_by_id, "type"))
+        self.assertEqual(enrollment_by_id.type, enrollment_type)
+
+        # by user object
+        enrollment_by_obj = self.section.enroll_user(
+            self.user, enrollment={"type": enrollment_type}
+        )
+
+        self.assertIsInstance(enrollment_by_obj, Enrollment)
+        self.assertTrue(hasattr(enrollment_by_obj, "type"))
+        self.assertEqual(enrollment_by_obj.type, enrollment_type)
diff --git a/tests/test_uploader.py b/tests/test_uploader.py
index 73a234db..d19d0497 100644
--- a/tests/test_uploader.py
+++ b/tests/test_uploader.py
@@ -1,5 +1,6 @@
 import unittest
 import uuid
+from pathlib import Path
 
 import requests_mock
 
@@ -34,6 +35,17 @@ def test_start(self, m):
         self.assertIsInstance(result[1], dict)
         self.assertIn("url", result[1])
 
+    def test_start_pathlib(self, m):
+        requires = {"uploader": ["upload_response", "upload_response_upload_url"]}
+        register_uris(requires, m)
+
+        uploader = Uploader(self.requester, "upload_response", Path(self.filename))
+        result = uploader.start()
+
+        self.assertTrue(result[0])
+        self.assertIsInstance(result[1], dict)
+        self.assertIn("url", result[1])
+
     def test_start_path(self, m):
         requires = {"uploader": ["upload_response", "upload_response_upload_url"]}
         register_uris(requires, m)
diff --git a/tests/test_user.py b/tests/test_user.py
index 0391d3af..2234e0ad 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -273,6 +273,15 @@ def test_get_file(self, m):
         self.assertEqual(file_by_obj.display_name, "User_File.docx")
         self.assertEqual(file_by_obj.size, 1024)
 
+    # get_file_quota()
+    def test_get_file_quota(self, m):
+        register_uris({"user": ["get_file_quota"]}, m)
+
+        file_quota = self.user.get_file_quota()
+        self.assertIsInstance(file_quota, dict)
+        self.assertEqual(file_quota["quota"], 889234510)
+        self.assertEqual(file_quota["quota_used"], 476231098)
+
     # get_folder()
     def test_get_folder(self, m):
         register_uris({"user": ["get_folder"]}, m)