Repository: ucfopen/canvasapi
Pull request #479 (2021-03-25 16:52:30)

Release v2.2.0
🎉 


Patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..2178065
--- /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 4b2fb8c..0000000
--- 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 a20241a..8b36f70 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 5fc59c7..744f739 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 bd9e965..254094e 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 ed36af6..274560f 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 2d3cf91..40a4957 100644
--- a/canvasapi/__init__.py
+++ b/canvasapi/__init__.py
@@ -4,4 +4,4 @@ from canvasapi.canvas import Canvas
 
 __all__ = ["Canvas"]
 
-__version__ = "2.1.0"
+__version__ = "2.2.0"
diff --git a/canvasapi/assignment.py b/canvasapi/assignment.py
index d987476..4b01566 100644
--- a/canvasapi/assignment.py
+++ b/canvasapi/assignment.py
@@ -4,7 +4,7 @@ from canvasapi.paginated_list import PaginatedList
 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 @@ class Assignment(CanvasObject):
             _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 @@ class Assignment(CanvasObject):
             _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 @@ class Assignment(CanvasObject):
         >>> 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 @@ class Assignment(CanvasObject):
             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 @@ class Assignment(CanvasObject):
 
         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 @@ class Assignment(CanvasObject):
         <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 962106e..51b632c 100644
--- a/canvasapi/canvas.py
+++ b/canvasapi/canvas.py
@@ -12,6 +12,7 @@ from canvasapi.group import Group, GroupCategory
 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 @@ class Canvas(object):
         )
         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 @@ class Canvas(object):
 
         :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 36aac1d..fcb97f0 100644
--- a/canvasapi/course.py
+++ b/canvasapi/course.py
@@ -28,7 +28,7 @@ from canvasapi.rubric import Rubric, RubricAssociation
 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 @@ class Course(CanvasObject):
         )
         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 @@ class Course(CanvasObject):
         :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 @@ class Course(CanvasObject):
         <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 @@ class Course(CanvasObject):
         )
         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 8cef986..476c8f7 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 @@ class Enrollment(CanvasObject):
                 )
             )
 
+        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 @@ class Enrollment(CanvasObject):
         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 3e4d22f..2ab31a9 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 a436ca7..0eefab7 100644
--- a/canvasapi/favorite.py
+++ b/canvasapi/favorite.py
@@ -17,11 +17,11 @@ class Favorite(CanvasObject):
 
         :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 0b27083..8ee6b36 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 @@ class Folder(CanvasObject):
 
         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 a228748..1acb666 100644
--- a/canvasapi/group.py
+++ b/canvasapi/group.py
@@ -6,6 +6,7 @@ from canvasapi.folder import Folder
 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 @@ class Group(CanvasObject):
         )
         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 @@ class Group(CanvasObject):
         )
         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 @@ class Group(CanvasObject):
                     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 2d236ce..b1b8745 100644
--- a/canvasapi/requester.py
+++ b/canvasapi/requester.py
@@ -10,6 +10,7 @@ from canvasapi.exceptions import (
     Conflict,
     Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -241,7 +242,15 @@ class Requester(object):
             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 05fdb4f..8046b64 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 @@ class Section(CanvasObject):
 
         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 390789c..6e1250e 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 @@ class Submission(CanvasObject):
             _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 @@ class Submission(CanvasObject):
         )
         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 e0a68fc..3767cc8 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 e7612aa..74c5079 100644
--- a/canvasapi/user.py
+++ b/canvasapi/user.py
@@ -6,7 +6,7 @@ from canvasapi.folder import Folder
 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 @@ class User(CanvasObject):
         )
         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 @@ class User(CanvasObject):
         )
         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 cc3419b..4e26ae6 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 5fa8501..af403bb 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 4132594..92969a8 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 d690360..e65387d 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 1a2e7f1..212542b 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 0000000..acbd48f
--- /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 e457e63..c6f6285 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 32db324..df2bc19 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 7c5b998..80cf284 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 c115946..638e106 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 ca123fc..1e0c7a5 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 a4eb936..82c5cde 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 11b77b1..5c49be5 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 e77a325..b86314b 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 @@ class TestAssignment(unittest.TestCase):
         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 @@ class TestAssignment(unittest.TestCase):
         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 87ec42a..84be6e2 100644
--- a/tests/test_canvas.py
+++ b/tests/test_canvas.py
@@ -19,8 +19,10 @@ from canvasapi.file import File
 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 @@ class TestCanvas(unittest.TestCase):
         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 @@ class TestCanvas(unittest.TestCase):
         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 2fca3dc..8f95829 100644
--- a/tests/test_course.py
+++ b/tests/test_course.py
@@ -766,6 +766,15 @@ class TestCourse(unittest.TestCase):
         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 7a49114..6b30759 100644
--- a/tests/test_favorite.py
+++ b/tests/test_favorite.py
@@ -16,38 +16,133 @@ class TestFavorite(unittest.TestCase):
         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 576be82..f5248d9 100644
--- a/tests/test_group.py
+++ b/tests/test_group.py
@@ -255,6 +255,15 @@ class TestGroup(unittest.TestCase):
         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 154bccc..da21776 100644
--- a/tests/test_poll.py
+++ b/tests/test_poll.py
@@ -48,26 +48,6 @@ class TestPoll(unittest.TestCase):
         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 df59eb9..f6a45a7 100644
--- a/tests/test_requester.py
+++ b/tests/test_requester.py
@@ -10,7 +10,9 @@ from canvasapi.exceptions import (
     BadRequest,
     CanvasException,
     Conflict,
+    Forbidden,
     InvalidAccessToken,
+    RateLimitExceeded,
     ResourceDoesNotExist,
     Unauthorized,
     UnprocessableEntity,
@@ -145,6 +147,34 @@ class TestRequester(unittest.TestCase):
         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 ce3f0ee..cc088d1 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -18,9 +18,14 @@ class TestSection(unittest.TestCase):
         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 @@ class TestSection(unittest.TestCase):
         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 73a234d..d19d049 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 @@ class TestUploader(unittest.TestCase):
         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 0391d3a..2234e0a 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -273,6 +273,15 @@ class TestUser(unittest.TestCase):
         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)