From 2b963569bb2c60eab735f7bc66bc59ee86490252 Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Thu, 25 Sep 2025 09:34:53 +0100 Subject: [PATCH 1/6] new metadata --- README.rst | 22 ++++++++------- pyproject.toml | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 pyproject.toml diff --git a/README.rst b/README.rst index 653c7fc..9a233ac 100644 --- a/README.rst +++ b/README.rst @@ -1,24 +1,26 @@ LTI 1.3 Advantage Tool implementation in Python =============================================== -.. image:: https://img.shields.io/pypi/v/PyLTI1p3 +This is a fork of the `pylti1p3 `_ package, originally written by Dmitry Viskov. + +.. image:: https://img.shields.io/pypi/v/pylti1p3next :scale: 100% - :target: https://pypi.python.org/pypi/PyLTI1p3 + :target: https://pypi.python.org/pypi/pylti1p3next :alt: PyPI -.. image:: https://img.shields.io/pypi/pyversions/PyLTI1p3 +.. image:: https://img.shields.io/pypi/pyversions/pylti1p3next :scale: 100% :target: https://www.python.org/ :alt: Python -.. image:: https://github.com/dmitry-viskov/pylti1.3/actions/workflows/tox.yml/badge.svg +.. image:: https://github.com/pymsglobal/pylti1p3next/actions/workflows/tox.yml/badge.svg :scale: 100% - :target: https://github.com/dmitry-viskov/pylti1.3/actions + :target: https://github.com/pymsglobal/pylti1p3next/actions :alt: Build Status -.. image:: https://img.shields.io/github/license/dmitry-viskov/pylti1.3 +.. image:: https://img.shields.io/github/license/pymsglobal/pylti1p3next :scale: 100% - :target: https://raw.githubusercontent.com/dmitry-viskov/pylti1.3/master/LICENSE + :target: https://raw.githubusercontent.com/pymsglobal/pylti1p3next/master/LICENSE :alt: MIT @@ -30,9 +32,9 @@ This library contains adapters for use with the Django and Flask web frameworks. Usage Examples ================= -Django: https://github.com/dmitry-viskov/pylti1.3-django-example +Django: https://github.com/pymsglobal/pylti1p3next-django-example -Flask: https://github.com/dmitry-viskov/pylti1.3-flask-example +Flask: https://github.com/pymsglobal/pylti1p3next-flask-example Configuration ============= @@ -581,7 +583,7 @@ After this, the special JS code will try to write and then read test cookie inst `special page`_ that will ask them to open the current URL in the new window if cookies are unavailable. If cookies are allowed, the user will be transparently redirected to the next page. All texts are configurable with passing arguments: -.. _special page: https://raw.githubusercontent.com/dmitry-viskov/repos-assets/master/pylti1p3/examples/cookies-check/001.png +.. _special page: https://raw.githubusercontent.com/pymsglobal/repos-assets/master/pylti1p3/examples/cookies-check/001.png .. code-block:: python diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ac92321 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[project] +name = "pylti1p3next" +dynamic = ["version"] +authors = [ + { name="Christian Lawson-Perfect", email="christian.perfect@ncl.ac.uk"}, + { name="Dmitry Viskov", email="dmitry.viskov@webenterprise.ru"} +] +maintainers = [ + { name="Sébastien Philippot", email="sebastien@philippot.co" }, + { name="Christian Lawson-Perfect", email="christian.perfect@ncl.ac.uk"} +] +description = "LTI 1.3 Advantage Tool implementation in Python" +readme = "README.rst" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.8" +keywords = [ + "pylti", + "pylti1p3", + "lti", + "lti1.3", + "lti1p3", + "django", + "flask" +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Flask", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: Education", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI", + "Topic :: Security", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "jwcrypto~=1.5", + "pyjwt~=1.5", + "requests~=2.32", + "typing_extensions~=4.2", +] + +[project.urls] +Homepage = "https://github.com/pymsglobal/pylti1p3next#readme" +Issues = "https://github.com/pymsglobal/pylti1p3next/issues" +Source = "https://github.com/pymsglobal/pylti1p3next" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +version = {attr = "pylti1p3.__version__"} + +[tool.coverage.paths] +pylti1p3next = ["pylti1p3"] +tests = ["tests"] From 06146f26b6edfef25bc46bd5766990d4eba6287f Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Thu, 25 Sep 2025 09:34:53 +0100 Subject: [PATCH 2/6] move to src layout --- setup.py | 72 ------------------- {pylti1p3 => src/pylti1p3}/__init__.py | 0 {pylti1p3 => src/pylti1p3}/actions.py | 0 .../pylti1p3}/assignments_grades.py | 6 +- .../pylti1p3}/contrib/__init__.py | 0 .../pylti1p3}/contrib/django/__init__.py | 0 .../pylti1p3}/contrib/django/cookie.py | 0 .../django/launch_data_storage/__init__.py | 0 .../django/launch_data_storage/cache.py | 0 .../django/lti1p3_tool_config/__init__.py | 0 .../django/lti1p3_tool_config/admin.py | 0 .../contrib/django/lti1p3_tool_config/apps.py | 0 .../migrations/0001_initial.py | 0 .../lti1p3_tool_config/migrations/__init__.py | 0 .../django/lti1p3_tool_config/models.py | 0 .../contrib/django/message_launch.py | 0 .../pylti1p3}/contrib/django/oidc_login.py | 0 .../pylti1p3}/contrib/django/redirect.py | 0 .../pylti1p3}/contrib/django/request.py | 0 .../pylti1p3}/contrib/django/session.py | 0 .../pylti1p3}/contrib/flask/__init__.py | 0 .../pylti1p3}/contrib/flask/cookie.py | 0 .../flask/launch_data_storage/__init__.py | 0 .../flask/launch_data_storage/cache.py | 0 .../pylti1p3}/contrib/flask/message_launch.py | 0 .../pylti1p3}/contrib/flask/oidc_login.py | 0 .../pylti1p3}/contrib/flask/redirect.py | 0 .../pylti1p3}/contrib/flask/request.py | 0 .../pylti1p3}/contrib/flask/session.py | 0 {pylti1p3 => src/pylti1p3}/contrib/py.typed | 0 {pylti1p3 => src/pylti1p3}/cookie.py | 0 .../pylti1p3}/cookies_allowed_check.py | 0 {pylti1p3 => src/pylti1p3}/course_groups.py | 0 {pylti1p3 => src/pylti1p3}/deep_link.py | 0 .../pylti1p3}/deep_link_resource.py | 0 {pylti1p3 => src/pylti1p3}/deployment.py | 0 {pylti1p3 => src/pylti1p3}/exception.py | 0 {pylti1p3 => src/pylti1p3}/grade.py | 0 .../pylti1p3}/launch_data_storage/__init__.py | 0 .../pylti1p3}/launch_data_storage/base.py | 0 .../pylti1p3}/launch_data_storage/cache.py | 0 .../pylti1p3}/launch_data_storage/session.py | 0 {pylti1p3 => src/pylti1p3}/lineitem.py | 0 {pylti1p3 => src/pylti1p3}/message_launch.py | 0 .../pylti1p3}/message_validators/__init__.py | 0 .../pylti1p3}/message_validators/abstract.py | 0 .../pylti1p3}/message_validators/deep_link.py | 0 .../message_validators/privacy_launch.py | 0 .../message_validators/resource_message.py | 0 .../message_validators/submission_review.py | 0 {pylti1p3 => src/pylti1p3}/names_roles.py | 0 {pylti1p3 => src/pylti1p3}/oidc_login.py | 0 {pylti1p3 => src/pylti1p3}/py.typed | 0 {pylti1p3 => src/pylti1p3}/redirect.py | 0 {pylti1p3 => src/pylti1p3}/registration.py | 0 {pylti1p3 => src/pylti1p3}/request.py | 0 {pylti1p3 => src/pylti1p3}/roles.py | 0 .../pylti1p3}/service_connector.py | 0 {pylti1p3 => src/pylti1p3}/session.py | 0 .../pylti1p3}/tool_config/__init__.py | 0 .../pylti1p3}/tool_config/abstract.py | 0 .../pylti1p3}/tool_config/dict.py | 0 .../pylti1p3}/tool_config/json_file.py | 0 .../pylti1p3}/tool_config/py.typed | 0 {pylti1p3 => src/pylti1p3}/utils.py | 0 tox.ini | 4 +- 66 files changed, 5 insertions(+), 77 deletions(-) delete mode 100644 setup.py rename {pylti1p3 => src/pylti1p3}/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/actions.py (100%) rename {pylti1p3 => src/pylti1p3}/assignments_grades.py (98%) rename {pylti1p3 => src/pylti1p3}/contrib/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/cookie.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/launch_data_storage/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/launch_data_storage/cache.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/admin.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/apps.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/migrations/0001_initial.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/migrations/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/lti1p3_tool_config/models.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/message_launch.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/oidc_login.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/redirect.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/request.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/django/session.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/cookie.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/launch_data_storage/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/launch_data_storage/cache.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/message_launch.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/oidc_login.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/redirect.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/request.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/flask/session.py (100%) rename {pylti1p3 => src/pylti1p3}/contrib/py.typed (100%) rename {pylti1p3 => src/pylti1p3}/cookie.py (100%) rename {pylti1p3 => src/pylti1p3}/cookies_allowed_check.py (100%) rename {pylti1p3 => src/pylti1p3}/course_groups.py (100%) rename {pylti1p3 => src/pylti1p3}/deep_link.py (100%) rename {pylti1p3 => src/pylti1p3}/deep_link_resource.py (100%) rename {pylti1p3 => src/pylti1p3}/deployment.py (100%) rename {pylti1p3 => src/pylti1p3}/exception.py (100%) rename {pylti1p3 => src/pylti1p3}/grade.py (100%) rename {pylti1p3 => src/pylti1p3}/launch_data_storage/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/launch_data_storage/base.py (100%) rename {pylti1p3 => src/pylti1p3}/launch_data_storage/cache.py (100%) rename {pylti1p3 => src/pylti1p3}/launch_data_storage/session.py (100%) rename {pylti1p3 => src/pylti1p3}/lineitem.py (100%) rename {pylti1p3 => src/pylti1p3}/message_launch.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/abstract.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/deep_link.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/privacy_launch.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/resource_message.py (100%) rename {pylti1p3 => src/pylti1p3}/message_validators/submission_review.py (100%) rename {pylti1p3 => src/pylti1p3}/names_roles.py (100%) rename {pylti1p3 => src/pylti1p3}/oidc_login.py (100%) rename {pylti1p3 => src/pylti1p3}/py.typed (100%) rename {pylti1p3 => src/pylti1p3}/redirect.py (100%) rename {pylti1p3 => src/pylti1p3}/registration.py (100%) rename {pylti1p3 => src/pylti1p3}/request.py (100%) rename {pylti1p3 => src/pylti1p3}/roles.py (100%) rename {pylti1p3 => src/pylti1p3}/service_connector.py (100%) rename {pylti1p3 => src/pylti1p3}/session.py (100%) rename {pylti1p3 => src/pylti1p3}/tool_config/__init__.py (100%) rename {pylti1p3 => src/pylti1p3}/tool_config/abstract.py (100%) rename {pylti1p3 => src/pylti1p3}/tool_config/dict.py (100%) rename {pylti1p3 => src/pylti1p3}/tool_config/json_file.py (100%) rename {pylti1p3 => src/pylti1p3}/tool_config/py.typed (100%) rename {pylti1p3 => src/pylti1p3}/utils.py (100%) diff --git a/setup.py b/setup.py deleted file mode 100644 index 8758cb4..0000000 --- a/setup.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import print_function - -import sys - -from setuptools import find_packages, setup - -from pylti1p3 import __version__ - -if sys.version_info < (3, 6): - error = "ERROR: PyLTI1p3 requires Python 3.6+ ... exiting." - print(error, file=sys.stderr) - sys.exit(1) - - -install_requires = [ - "jwcrypto", - "pyjwt>=1.5", - "requests", - "typing_extensions", -] - -with open("README.rst", "rt") as readme: - long_description = readme.read().strip() - -packages = find_packages(exclude=["examples", "tests"]) - -setup( - name="PyLTI1p3", - version=__version__, - description="LTI 1.3 Advantage Tool implementation in Python", - keywords="pylti,pylti1p3,lti,lti1.3,lti1p3,django,flask", - author="Dmitry Viskov", - author_email="dmitry.viskov@webenterprise.ru", - maintainer="Dmitry Viskov", - long_description=long_description, - install_requires=install_requires, - license="MIT", - url="https://github.com/dmitry-viskov/pylti1.3", - packages=packages, - zip_safe=False, - include_package_data=True, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Flask", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: WSGI", - "Topic :: Security", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - package_data={ - "pylti1p3": ["py.typed"], - "pylti1p3.tool_config": ["py.typed"], - "pylti1p3.contrib": ["py.typed"], - }, -) diff --git a/pylti1p3/__init__.py b/src/pylti1p3/__init__.py similarity index 100% rename from pylti1p3/__init__.py rename to src/pylti1p3/__init__.py diff --git a/pylti1p3/actions.py b/src/pylti1p3/actions.py similarity index 100% rename from pylti1p3/actions.py rename to src/pylti1p3/actions.py diff --git a/pylti1p3/assignments_grades.py b/src/pylti1p3/assignments_grades.py similarity index 98% rename from pylti1p3/assignments_grades.py rename to src/pylti1p3/assignments_grades.py index d0db759..2b492b6 100644 --- a/pylti1p3/assignments_grades.py +++ b/src/pylti1p3/assignments_grades.py @@ -249,7 +249,7 @@ def find_or_create_lineitem( if not self.can_create_lineitem(): raise LtiException("Can't create lineitem: Missing required scope") - created_lineitem = self._service_connector.make_service_request( + response = self._service_connector.make_service_request( self._service_data["scope"], self._service_data["lineitems"], is_post=True, @@ -257,9 +257,9 @@ def find_or_create_lineitem( content_type="application/vnd.ims.lis.v2.lineitem+json", accept="application/vnd.ims.lis.v2.lineitem+json", ) - if not isinstance(created_lineitem["body"], dict): + if not isinstance(response["body"], dict): raise LtiException("Unknown response type received for create line item") - return LineItem(t.cast(TLineItem, created_lineitem["body"])) + return LineItem(t.cast(TLineItem, response["body"])) def get_grades(self, lineitem: t.Optional[LineItem] = None) -> list: """ diff --git a/pylti1p3/contrib/__init__.py b/src/pylti1p3/contrib/__init__.py similarity index 100% rename from pylti1p3/contrib/__init__.py rename to src/pylti1p3/contrib/__init__.py diff --git a/pylti1p3/contrib/django/__init__.py b/src/pylti1p3/contrib/django/__init__.py similarity index 100% rename from pylti1p3/contrib/django/__init__.py rename to src/pylti1p3/contrib/django/__init__.py diff --git a/pylti1p3/contrib/django/cookie.py b/src/pylti1p3/contrib/django/cookie.py similarity index 100% rename from pylti1p3/contrib/django/cookie.py rename to src/pylti1p3/contrib/django/cookie.py diff --git a/pylti1p3/contrib/django/launch_data_storage/__init__.py b/src/pylti1p3/contrib/django/launch_data_storage/__init__.py similarity index 100% rename from pylti1p3/contrib/django/launch_data_storage/__init__.py rename to src/pylti1p3/contrib/django/launch_data_storage/__init__.py diff --git a/pylti1p3/contrib/django/launch_data_storage/cache.py b/src/pylti1p3/contrib/django/launch_data_storage/cache.py similarity index 100% rename from pylti1p3/contrib/django/launch_data_storage/cache.py rename to src/pylti1p3/contrib/django/launch_data_storage/cache.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/__init__.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/admin.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/admin.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/admin.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/admin.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/apps.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/apps.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/apps.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/apps.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/migrations/0001_initial.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/migrations/__init__.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/migrations/__init__.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/migrations/__init__.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/migrations/__init__.py diff --git a/pylti1p3/contrib/django/lti1p3_tool_config/models.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/models.py similarity index 100% rename from pylti1p3/contrib/django/lti1p3_tool_config/models.py rename to src/pylti1p3/contrib/django/lti1p3_tool_config/models.py diff --git a/pylti1p3/contrib/django/message_launch.py b/src/pylti1p3/contrib/django/message_launch.py similarity index 100% rename from pylti1p3/contrib/django/message_launch.py rename to src/pylti1p3/contrib/django/message_launch.py diff --git a/pylti1p3/contrib/django/oidc_login.py b/src/pylti1p3/contrib/django/oidc_login.py similarity index 100% rename from pylti1p3/contrib/django/oidc_login.py rename to src/pylti1p3/contrib/django/oidc_login.py diff --git a/pylti1p3/contrib/django/redirect.py b/src/pylti1p3/contrib/django/redirect.py similarity index 100% rename from pylti1p3/contrib/django/redirect.py rename to src/pylti1p3/contrib/django/redirect.py diff --git a/pylti1p3/contrib/django/request.py b/src/pylti1p3/contrib/django/request.py similarity index 100% rename from pylti1p3/contrib/django/request.py rename to src/pylti1p3/contrib/django/request.py diff --git a/pylti1p3/contrib/django/session.py b/src/pylti1p3/contrib/django/session.py similarity index 100% rename from pylti1p3/contrib/django/session.py rename to src/pylti1p3/contrib/django/session.py diff --git a/pylti1p3/contrib/flask/__init__.py b/src/pylti1p3/contrib/flask/__init__.py similarity index 100% rename from pylti1p3/contrib/flask/__init__.py rename to src/pylti1p3/contrib/flask/__init__.py diff --git a/pylti1p3/contrib/flask/cookie.py b/src/pylti1p3/contrib/flask/cookie.py similarity index 100% rename from pylti1p3/contrib/flask/cookie.py rename to src/pylti1p3/contrib/flask/cookie.py diff --git a/pylti1p3/contrib/flask/launch_data_storage/__init__.py b/src/pylti1p3/contrib/flask/launch_data_storage/__init__.py similarity index 100% rename from pylti1p3/contrib/flask/launch_data_storage/__init__.py rename to src/pylti1p3/contrib/flask/launch_data_storage/__init__.py diff --git a/pylti1p3/contrib/flask/launch_data_storage/cache.py b/src/pylti1p3/contrib/flask/launch_data_storage/cache.py similarity index 100% rename from pylti1p3/contrib/flask/launch_data_storage/cache.py rename to src/pylti1p3/contrib/flask/launch_data_storage/cache.py diff --git a/pylti1p3/contrib/flask/message_launch.py b/src/pylti1p3/contrib/flask/message_launch.py similarity index 100% rename from pylti1p3/contrib/flask/message_launch.py rename to src/pylti1p3/contrib/flask/message_launch.py diff --git a/pylti1p3/contrib/flask/oidc_login.py b/src/pylti1p3/contrib/flask/oidc_login.py similarity index 100% rename from pylti1p3/contrib/flask/oidc_login.py rename to src/pylti1p3/contrib/flask/oidc_login.py diff --git a/pylti1p3/contrib/flask/redirect.py b/src/pylti1p3/contrib/flask/redirect.py similarity index 100% rename from pylti1p3/contrib/flask/redirect.py rename to src/pylti1p3/contrib/flask/redirect.py diff --git a/pylti1p3/contrib/flask/request.py b/src/pylti1p3/contrib/flask/request.py similarity index 100% rename from pylti1p3/contrib/flask/request.py rename to src/pylti1p3/contrib/flask/request.py diff --git a/pylti1p3/contrib/flask/session.py b/src/pylti1p3/contrib/flask/session.py similarity index 100% rename from pylti1p3/contrib/flask/session.py rename to src/pylti1p3/contrib/flask/session.py diff --git a/pylti1p3/contrib/py.typed b/src/pylti1p3/contrib/py.typed similarity index 100% rename from pylti1p3/contrib/py.typed rename to src/pylti1p3/contrib/py.typed diff --git a/pylti1p3/cookie.py b/src/pylti1p3/cookie.py similarity index 100% rename from pylti1p3/cookie.py rename to src/pylti1p3/cookie.py diff --git a/pylti1p3/cookies_allowed_check.py b/src/pylti1p3/cookies_allowed_check.py similarity index 100% rename from pylti1p3/cookies_allowed_check.py rename to src/pylti1p3/cookies_allowed_check.py diff --git a/pylti1p3/course_groups.py b/src/pylti1p3/course_groups.py similarity index 100% rename from pylti1p3/course_groups.py rename to src/pylti1p3/course_groups.py diff --git a/pylti1p3/deep_link.py b/src/pylti1p3/deep_link.py similarity index 100% rename from pylti1p3/deep_link.py rename to src/pylti1p3/deep_link.py diff --git a/pylti1p3/deep_link_resource.py b/src/pylti1p3/deep_link_resource.py similarity index 100% rename from pylti1p3/deep_link_resource.py rename to src/pylti1p3/deep_link_resource.py diff --git a/pylti1p3/deployment.py b/src/pylti1p3/deployment.py similarity index 100% rename from pylti1p3/deployment.py rename to src/pylti1p3/deployment.py diff --git a/pylti1p3/exception.py b/src/pylti1p3/exception.py similarity index 100% rename from pylti1p3/exception.py rename to src/pylti1p3/exception.py diff --git a/pylti1p3/grade.py b/src/pylti1p3/grade.py similarity index 100% rename from pylti1p3/grade.py rename to src/pylti1p3/grade.py diff --git a/pylti1p3/launch_data_storage/__init__.py b/src/pylti1p3/launch_data_storage/__init__.py similarity index 100% rename from pylti1p3/launch_data_storage/__init__.py rename to src/pylti1p3/launch_data_storage/__init__.py diff --git a/pylti1p3/launch_data_storage/base.py b/src/pylti1p3/launch_data_storage/base.py similarity index 100% rename from pylti1p3/launch_data_storage/base.py rename to src/pylti1p3/launch_data_storage/base.py diff --git a/pylti1p3/launch_data_storage/cache.py b/src/pylti1p3/launch_data_storage/cache.py similarity index 100% rename from pylti1p3/launch_data_storage/cache.py rename to src/pylti1p3/launch_data_storage/cache.py diff --git a/pylti1p3/launch_data_storage/session.py b/src/pylti1p3/launch_data_storage/session.py similarity index 100% rename from pylti1p3/launch_data_storage/session.py rename to src/pylti1p3/launch_data_storage/session.py diff --git a/pylti1p3/lineitem.py b/src/pylti1p3/lineitem.py similarity index 100% rename from pylti1p3/lineitem.py rename to src/pylti1p3/lineitem.py diff --git a/pylti1p3/message_launch.py b/src/pylti1p3/message_launch.py similarity index 100% rename from pylti1p3/message_launch.py rename to src/pylti1p3/message_launch.py diff --git a/pylti1p3/message_validators/__init__.py b/src/pylti1p3/message_validators/__init__.py similarity index 100% rename from pylti1p3/message_validators/__init__.py rename to src/pylti1p3/message_validators/__init__.py diff --git a/pylti1p3/message_validators/abstract.py b/src/pylti1p3/message_validators/abstract.py similarity index 100% rename from pylti1p3/message_validators/abstract.py rename to src/pylti1p3/message_validators/abstract.py diff --git a/pylti1p3/message_validators/deep_link.py b/src/pylti1p3/message_validators/deep_link.py similarity index 100% rename from pylti1p3/message_validators/deep_link.py rename to src/pylti1p3/message_validators/deep_link.py diff --git a/pylti1p3/message_validators/privacy_launch.py b/src/pylti1p3/message_validators/privacy_launch.py similarity index 100% rename from pylti1p3/message_validators/privacy_launch.py rename to src/pylti1p3/message_validators/privacy_launch.py diff --git a/pylti1p3/message_validators/resource_message.py b/src/pylti1p3/message_validators/resource_message.py similarity index 100% rename from pylti1p3/message_validators/resource_message.py rename to src/pylti1p3/message_validators/resource_message.py diff --git a/pylti1p3/message_validators/submission_review.py b/src/pylti1p3/message_validators/submission_review.py similarity index 100% rename from pylti1p3/message_validators/submission_review.py rename to src/pylti1p3/message_validators/submission_review.py diff --git a/pylti1p3/names_roles.py b/src/pylti1p3/names_roles.py similarity index 100% rename from pylti1p3/names_roles.py rename to src/pylti1p3/names_roles.py diff --git a/pylti1p3/oidc_login.py b/src/pylti1p3/oidc_login.py similarity index 100% rename from pylti1p3/oidc_login.py rename to src/pylti1p3/oidc_login.py diff --git a/pylti1p3/py.typed b/src/pylti1p3/py.typed similarity index 100% rename from pylti1p3/py.typed rename to src/pylti1p3/py.typed diff --git a/pylti1p3/redirect.py b/src/pylti1p3/redirect.py similarity index 100% rename from pylti1p3/redirect.py rename to src/pylti1p3/redirect.py diff --git a/pylti1p3/registration.py b/src/pylti1p3/registration.py similarity index 100% rename from pylti1p3/registration.py rename to src/pylti1p3/registration.py diff --git a/pylti1p3/request.py b/src/pylti1p3/request.py similarity index 100% rename from pylti1p3/request.py rename to src/pylti1p3/request.py diff --git a/pylti1p3/roles.py b/src/pylti1p3/roles.py similarity index 100% rename from pylti1p3/roles.py rename to src/pylti1p3/roles.py diff --git a/pylti1p3/service_connector.py b/src/pylti1p3/service_connector.py similarity index 100% rename from pylti1p3/service_connector.py rename to src/pylti1p3/service_connector.py diff --git a/pylti1p3/session.py b/src/pylti1p3/session.py similarity index 100% rename from pylti1p3/session.py rename to src/pylti1p3/session.py diff --git a/pylti1p3/tool_config/__init__.py b/src/pylti1p3/tool_config/__init__.py similarity index 100% rename from pylti1p3/tool_config/__init__.py rename to src/pylti1p3/tool_config/__init__.py diff --git a/pylti1p3/tool_config/abstract.py b/src/pylti1p3/tool_config/abstract.py similarity index 100% rename from pylti1p3/tool_config/abstract.py rename to src/pylti1p3/tool_config/abstract.py diff --git a/pylti1p3/tool_config/dict.py b/src/pylti1p3/tool_config/dict.py similarity index 100% rename from pylti1p3/tool_config/dict.py rename to src/pylti1p3/tool_config/dict.py diff --git a/pylti1p3/tool_config/json_file.py b/src/pylti1p3/tool_config/json_file.py similarity index 100% rename from pylti1p3/tool_config/json_file.py rename to src/pylti1p3/tool_config/json_file.py diff --git a/pylti1p3/tool_config/py.typed b/src/pylti1p3/tool_config/py.typed similarity index 100% rename from pylti1p3/tool_config/py.typed rename to src/pylti1p3/tool_config/py.typed diff --git a/pylti1p3/utils.py b/src/pylti1p3/utils.py similarity index 100% rename from pylti1p3/utils.py rename to src/pylti1p3/utils.py diff --git a/tox.ini b/tox.ini index 51058bd..b046360 100644 --- a/tox.ini +++ b/tox.ini @@ -5,8 +5,8 @@ envlist = py36, py37, py38, py39, py310, py311 commands = flake8 . pylint --rcfile=pylintrc pylti1p3 tests - mypy pylti1p3 - black . --check --diff + mypy src + black src --check --diff coverage run -m unittest -v tests coverage report -m deps = From f68144523e339f22ce406a5cd6b7e9ac2438e003 Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Fri, 7 Jun 2024 16:41:35 +0100 Subject: [PATCH 3/6] flake8 and pylint ignore migrations They're automatically generated, so I don't think we need to lint them. --- pylintrc | 4 ++-- setup.cfg | 2 +- tox.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pylintrc b/pylintrc index 948bbe4..450a5aa 100644 --- a/pylintrc +++ b/pylintrc @@ -42,13 +42,13 @@ fail-under=10 #from-stdin= # Files or directories to be skipped. They should be base names, not paths. -ignore=CVS +ignore= # Add files or directories matching the regular expressions patterns to the # ignore-list. The regex matches against paths and can be in Posix or Windows # format. Because '\' represents the directory delimiter on Windows systems, it # can't be used as an escape character. -ignore-paths= +ignore-paths=^.*/migrations/.*$ # Files or directories matching the regular expression patterns are skipped. # The regex matches against base names, not paths. The default value ignores diff --git a/setup.cfg b/setup.cfg index 2cd4d0c..769f74c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ license_file = LICENSE [flake8] max_line_length=120 -exclude=.git,.idea,.tox,build,venv,venv3,env +exclude=.git,.idea,.tox,build,venv,venv3,env,migrations extend-ignore=E203 [coverage:run] diff --git a/tox.ini b/tox.ini index b046360..427cf1f 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ commands = flake8 . pylint --rcfile=pylintrc pylti1p3 tests mypy src - black src --check --diff + black src --check --diff --extend-exclude ".*/migrations/.*" coverage run -m unittest -v tests coverage report -m deps = From 7c43bade60d65b532c7aaeed82aefe28987a6e34 Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Fri, 7 Jun 2024 16:41:35 +0100 Subject: [PATCH 4/6] pylintrc: fix overgeneral-exceptions Apparently pylint wants you to get BaseException and Exception from builtins --- pylintrc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylintrc b/pylintrc index 450a5aa..ae37809 100644 --- a/pylintrc +++ b/pylintrc @@ -307,8 +307,8 @@ min-public-methods=2 [EXCEPTIONS] # Exceptions that will emit a warning when caught. -overgeneral-exceptions=BaseException, - Exception +overgeneral-exceptions=builtins.BaseException, + builtins.Exception [FORMAT] From 551ad022fcb8774da00356578aae5ffaa602060a Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Fri, 7 Jun 2024 16:41:35 +0100 Subject: [PATCH 5/6] Changes to appease pylint As well as formatting changes, there are a few methods which pylint said had too many positional arguments, so I've changed them to keyword arguments. Anything using those methods will have to make sure the keyword arguments are named. --- pylintrc | 3 ++- src/pylti1p3/assignments_grades.py | 4 ++-- .../django/lti1p3_tool_config/__init__.py | 1 - .../django/lti1p3_tool_config/models.py | 6 +++--- src/pylti1p3/contrib/django/message_launch.py | 13 +++++++------ src/pylti1p3/contrib/django/oidc_login.py | 11 ++++++----- src/pylti1p3/contrib/django/request.py | 19 ++++++++----------- src/pylti1p3/contrib/flask/cookie.py | 16 ++++++++-------- src/pylti1p3/contrib/flask/message_launch.py | 13 +++++++------ src/pylti1p3/contrib/flask/oidc_login.py | 7 ++++++- src/pylti1p3/cookies_allowed_check.py | 2 +- src/pylti1p3/message_launch.py | 2 ++ src/pylti1p3/oidc_login.py | 3 ++- src/pylti1p3/service_connector.py | 1 + src/pylti1p3/session.py | 2 +- src/pylti1p3/tool_config/abstract.py | 12 ++++++------ tests/base.py | 1 + tests/django_mixin.py | 2 ++ tests/flask_mixin.py | 2 ++ tests/request.py | 4 +++- tests/test_grades.py | 2 +- tests/test_resource_link.py | 19 ++++++++++++------- tox.ini | 2 ++ 23 files changed, 85 insertions(+), 62 deletions(-) diff --git a/pylintrc b/pylintrc index ae37809..c1e3810 100644 --- a/pylintrc +++ b/pylintrc @@ -77,7 +77,7 @@ limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. -load-plugins= +load-plugins=pylint_django # Pickle collected data for later comparisons. persistent=yes @@ -428,6 +428,7 @@ disable=bad-inline-option, use-symbolic-message-instead, useless-option-value, useless-suppression, + django-not-configured, # Enable the message, report, category or checker with the given id(s). You can diff --git a/src/pylti1p3/assignments_grades.py b/src/pylti1p3/assignments_grades.py index 2b492b6..c7ad738 100644 --- a/src/pylti1p3/assignments_grades.py +++ b/src/pylti1p3/assignments_grades.py @@ -250,8 +250,8 @@ def find_or_create_lineitem( raise LtiException("Can't create lineitem: Missing required scope") response = self._service_connector.make_service_request( - self._service_data["scope"], - self._service_data["lineitems"], + scopes=self._service_data["scope"], + url=self._service_data["lineitems"], is_post=True, data=new_lineitem.get_value(), content_type="application/vnd.ims.lis.v2.lineitem+json", diff --git a/src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py index ed5a3bc..0754f45 100644 --- a/src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py +++ b/src/pylti1p3/contrib/django/lti1p3_tool_config/__init__.py @@ -1,5 +1,4 @@ import json - from pylti1p3.deployment import Deployment from pylti1p3.exception import LtiException from pylti1p3.registration import Registration diff --git a/src/pylti1p3/contrib/django/lti1p3_tool_config/models.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/models.py index 6e5ff73..617768c 100644 --- a/src/pylti1p3/contrib/django/lti1p3_tool_config/models.py +++ b/src/pylti1p3/contrib/django/lti1p3_tool_config/models.py @@ -170,9 +170,9 @@ def to_dict(self): "auth_audience": self.auth_audience, "key_set_url": self.key_set_url, "key_set": json.loads(self.key_set) if self.key_set else None, - "deployment_ids": json.loads(self.deployment_ids) - if self.deployment_ids - else [], + "deployment_ids": ( + json.loads(self.deployment_ids) if self.deployment_ids else [] + ), } return data diff --git a/src/pylti1p3/contrib/django/message_launch.py b/src/pylti1p3/contrib/django/message_launch.py index 06c4cd1..c6dda08 100644 --- a/src/pylti1p3/contrib/django/message_launch.py +++ b/src/pylti1p3/contrib/django/message_launch.py @@ -10,6 +10,7 @@ def __init__( self, request, tool_config, + *, session_service=None, cookie_service=None, launch_data_storage=None, @@ -27,12 +28,12 @@ def __init__( session_service if session_service else DjangoSessionService(request) ) super().__init__( - django_request, - tool_config, - session_service, - cookie_service, - launch_data_storage, - requests_session, + request=django_request, + tool_config=tool_config, + session_service=session_service, + cookie_service=cookie_service, + launch_data_storage=launch_data_storage, + requests_session=requests_session, ) def _get_request_param(self, key): diff --git a/src/pylti1p3/contrib/django/oidc_login.py b/src/pylti1p3/contrib/django/oidc_login.py index fd6a828..b75546c 100644 --- a/src/pylti1p3/contrib/django/oidc_login.py +++ b/src/pylti1p3/contrib/django/oidc_login.py @@ -13,6 +13,7 @@ def __init__( self, request, tool_config, + *, session_service=None, cookie_service=None, launch_data_storage=None, @@ -27,11 +28,11 @@ def __init__( session_service if session_service else DjangoSessionService(request) ) super().__init__( - django_request, - tool_config, - session_service, - cookie_service, - launch_data_storage, + request=django_request, + tool_config=tool_config, + session_service=session_service, + cookie_service=cookie_service, + launch_data_storage=launch_data_storage, ) def get_redirect(self, url): diff --git a/src/pylti1p3/contrib/django/request.py b/src/pylti1p3/contrib/django/request.py index bc314c3..28763a6 100644 --- a/src/pylti1p3/contrib/django/request.py +++ b/src/pylti1p3/contrib/django/request.py @@ -2,31 +2,28 @@ class DjangoRequest(Request): - _request = None + request = None _post_only = False _default_params = None @property def session(self): - return self._request.session + return self.request.session def __init__(self, request, post_only=False, default_params=None): - self.set_request(request) + self.request = request self._post_only = post_only self._default_params = default_params if default_params else {} - def set_request(self, request): - self._request = request - def get_param(self, key): if self._post_only: - return self._request.POST.get(key, self._default_params.get(key)) - return self._request.GET.get( - key, self._request.POST.get(key, self._default_params.get(key)) + return self.request.POST.get(key, self._default_params.get(key)) + return self.request.GET.get( + key, self.request.POST.get(key, self._default_params.get(key)) ) def get_cookie(self, key): - return self._request.COOKIES.get(key) + return self.request.COOKIES.get(key) def is_secure(self): - return self._request.is_secure() + return self.request.is_secure() diff --git a/src/pylti1p3/contrib/flask/cookie.py b/src/pylti1p3/contrib/flask/cookie.py index 775855f..b9973fc 100644 --- a/src/pylti1p3/contrib/flask/cookie.py +++ b/src/pylti1p3/contrib/flask/cookie.py @@ -20,14 +20,14 @@ def set_cookie(self, name, value, exp=3600): def update_response(self, response): for key, cookie_data in self._cookie_data_to_set.items(): - cookie_kwargs = dict( - key=key, - value=cookie_data["value"], - max_age=cookie_data["exp"], - secure=self._request.is_secure(), - path="/", - httponly=True, - ) + cookie_kwargs = { + "key": key, + "value": cookie_data["value"], + "max_age": cookie_data["exp"], + "secure": self._request.is_secure(), + "path": "/", + "httponly": True, + } if self._request.is_secure(): cookie_kwargs["samesite"] = "None" diff --git a/src/pylti1p3/contrib/flask/message_launch.py b/src/pylti1p3/contrib/flask/message_launch.py index b811995..427a8b0 100644 --- a/src/pylti1p3/contrib/flask/message_launch.py +++ b/src/pylti1p3/contrib/flask/message_launch.py @@ -8,6 +8,7 @@ def __init__( self, request, tool_config, + *, session_service=None, cookie_service=None, launch_data_storage=None, @@ -20,12 +21,12 @@ def __init__( session_service if session_service else FlaskSessionService(request) ) super().__init__( - request, - tool_config, - session_service, - cookie_service, - launch_data_storage, - requests_session, + request=request, + tool_config=tool_config, + session_service=session_service, + cookie_service=cookie_service, + launch_data_storage=launch_data_storage, + requests_session=requests_session, ) def _get_request_param(self, key): diff --git a/src/pylti1p3/contrib/flask/oidc_login.py b/src/pylti1p3/contrib/flask/oidc_login.py index b0fee37..f0a2a94 100644 --- a/src/pylti1p3/contrib/flask/oidc_login.py +++ b/src/pylti1p3/contrib/flask/oidc_login.py @@ -10,6 +10,7 @@ def __init__( self, request, tool_config, + *, session_service=None, cookie_service=None, launch_data_storage=None, @@ -21,7 +22,11 @@ def __init__( session_service if session_service else FlaskSessionService(request) ) super().__init__( - request, tool_config, session_service, cookie_service, launch_data_storage + request=request, + tool_config=tool_config, + session_service=session_service, + cookie_service=cookie_service, + launch_data_storage=launch_data_storage, ) def get_redirect(self, url): diff --git a/src/pylti1p3/cookies_allowed_check.py b/src/pylti1p3/cookies_allowed_check.py index 388d924..5b75e0c 100644 --- a/src/pylti1p3/cookies_allowed_check.py +++ b/src/pylti1p3/cookies_allowed_check.py @@ -18,7 +18,7 @@ def __init__( click_text: str, loading_text: str, *args, - **kwargs + **kwargs, ): # pylint: disable=unused-argument self._params = params diff --git a/src/pylti1p3/message_launch.py b/src/pylti1p3/message_launch.py index 4143900..a5f2c04 100644 --- a/src/pylti1p3/message_launch.py +++ b/src/pylti1p3/message_launch.py @@ -204,6 +204,7 @@ def __init__( self, request: REQ, tool_config: TCONF, + *, session_service: t.Optional[SES] = None, cookie_service: t.Optional[COOK] = None, launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, @@ -279,6 +280,7 @@ def from_cache( launch_id: str, request: REQ, tool_config: TCONF, + *, session_service: t.Optional[SES] = None, cookie_service: t.Optional[COOK] = None, launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, diff --git a/src/pylti1p3/oidc_login.py b/src/pylti1p3/oidc_login.py index 6d23e01..3496176 100644 --- a/src/pylti1p3/oidc_login.py +++ b/src/pylti1p3/oidc_login.py @@ -45,6 +45,7 @@ def __init__( self, request: REQ, tool_config: TCONF, + *, session_service: SES, cookie_service: COOK, launch_data_storage: t.Optional[LaunchDataStorage[t.Any]] = None, @@ -204,7 +205,7 @@ def enable_check_cookies( main_msg: t.Optional[str] = None, click_msg: t.Optional[str] = None, loading_msg: t.Optional[str] = None, - **kwargs + **kwargs, ) -> "OIDCLogin": # pylint: disable=unused-argument self._cookies_check = True diff --git a/src/pylti1p3/service_connector.py b/src/pylti1p3/service_connector.py index d50149f..c391039 100644 --- a/src/pylti1p3/service_connector.py +++ b/src/pylti1p3/service_connector.py @@ -108,6 +108,7 @@ def make_service_request( self, scopes: t.Sequence[str], url: str, + *, is_post: bool = False, data: t.Optional[str] = None, content_type: str = "application/json", diff --git a/src/pylti1p3/session.py b/src/pylti1p3/session.py index e28078b..75a26ab 100644 --- a/src/pylti1p3/session.py +++ b/src/pylti1p3/session.py @@ -66,5 +66,5 @@ def set_launch_data_lifetime(self, time_sec: int): else: raise Exception( f"{self.data_storage.__class__.__name__} launch storage doesn't support " - f"manual change expiration of the keys" + f"changing the expiration time of keys" ) diff --git a/src/pylti1p3/tool_config/abstract.py b/src/pylti1p3/tool_config/abstract.py index e97d07e..84847fc 100644 --- a/src/pylti1p3/tool_config/abstract.py +++ b/src/pylti1p3/tool_config/abstract.py @@ -39,14 +39,14 @@ def check_iss_has_many_clients(self, iss: str) -> bool: return iss_type == IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER def set_iss_has_one_client(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + ) def set_iss_has_many_clients(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + ) def find_registration(self, iss: str, *args, **kwargs) -> Registration: """ diff --git a/tests/base.py b/tests/base.py index f9ea55d..5456f75 100644 --- a/tests/base.py +++ b/tests/base.py @@ -20,6 +20,7 @@ def _launch( self, request, tool_conf, + *, key_set_url_response=None, force_validation=False, cache=False, diff --git a/tests/django_mixin.py b/tests/django_mixin.py index aa6d7c9..7fba0a7 100644 --- a/tests/django_mixin.py +++ b/tests/django_mixin.py @@ -10,6 +10,7 @@ class DjangoMixin: def _get_request( self, + *, login_request, login_response, request_is_secure=False, @@ -29,6 +30,7 @@ def _get_request( def _make_oidc_login( self, + *, uuid_val=None, tool_conf_cls=None, secure=False, diff --git a/tests/flask_mixin.py b/tests/flask_mixin.py index b2bb514..7bd3beb 100644 --- a/tests/flask_mixin.py +++ b/tests/flask_mixin.py @@ -18,6 +18,7 @@ def _get_request( self, login_request, login_response, + *, request_is_secure=False, post_data=None, empty_session=False, @@ -37,6 +38,7 @@ def _get_request( def _make_oidc_login( self, + *, uuid_val=None, tool_conf_cls=None, secure=False, diff --git a/tests/request.py b/tests/request.py index a2e1076..e5bdaaa 100644 --- a/tests/request.py +++ b/tests/request.py @@ -8,7 +8,9 @@ class FakeRequest: session = None secure = False - def __init__(self, get=None, post=None, cookies=None, session=None, secure=False): + def __init__( + self, *, get=None, post=None, cookies=None, session=None, secure=False + ): self.GET = get if get else {} self.POST = post if post else {} self.COOKIES = cookies if cookies else {} diff --git a/tests/test_grades.py b/tests/test_grades.py index e83a0d4..a16fae1 100644 --- a/tests/test_grades.py +++ b/tests/test_grades.py @@ -91,7 +91,7 @@ def test_get_grades( line_item = ags.find_or_create_lineitem(score_line_item) self.assertIsNotNone(line_item) - scores = ags.get_grades(line_item) + scores = list(ags.get_grades(line_item)) self.assertEqual(len(scores), 1) self.assertDictEqual( scores[0], diff --git a/tests/test_resource_link.py b/tests/test_resource_link.py index cb669c4..e402e78 100644 --- a/tests/test_resource_link.py +++ b/tests/test_resource_link.py @@ -176,6 +176,7 @@ class ResourceLinkBase(TestLinkBase): def _launch_success( self, + *, tool_conf_cls=None, secure=False, tool_conf_extended=False, @@ -204,9 +205,11 @@ def _launch_success( ] ) def test_res_link_launch_success( - self, name, secure, tool_conf_extended # pylint: disable=unused-argument - ): - self._launch_success(None, secure, tool_conf_extended) + self, name, secure, tool_conf_extended + ): # pylint: disable=unused-argument,too-many-positional-arguments,too-many-function-args + self._launch_success( + tool_conf_cls=None, secure=secure, tool_conf_extended=tool_conf_extended + ) def test_res_link_check_cookies_page(self): self._launch_success(enable_check_cookies=True) @@ -219,7 +222,9 @@ def test_res_link_launch_invalid_public_key(self): launch_request = self._get_request(login_request, login_response) with self.assertRaisesRegex(LtiException, "Invalid response"): - self._launch(launch_request, tool_conf, "invalid_key_set") + self._launch( + launch_request, tool_conf, key_set_url_response="invalid_key_set" + ) def test_res_link_launch_invalid_state(self): tool_conf, login_request, login_response = self._make_oidc_login() @@ -293,9 +298,9 @@ def _get_data_with_invalid_deployment( def _get_data_with_invalid_message(self, *args): # pylint: disable=unused-argument message_launch_data = self.expected_message_launch_data.copy() - message_launch_data[ - "https://purl.imsglobal.org/spec/lti/claim/version" - ] = "1.2.0" + message_launch_data["https://purl.imsglobal.org/spec/lti/claim/version"] = ( + "1.2.0" + ) return message_launch_data def test_res_link_launch_invalid_nonce(self): diff --git a/tox.ini b/tox.ini index 427cf1f..3e066eb 100644 --- a/tox.ini +++ b/tox.ini @@ -24,3 +24,5 @@ deps = requests requests-mock types-requests + django-types + pylint-django From b7105377a71a0ffcc481b7521f16fe1a59fb14ca Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Mon, 20 Feb 2023 09:37:16 +0000 Subject: [PATCH 6/6] add handlers for dynamic registration See https://github.com/christianp/pylti1.3-django-example/tree/dynamic-registration for an example. --- .../dynamic_registration.py | 112 ++++++ src/pylti1p3/dynamic_registration.py | 328 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/pylti1p3/contrib/django/lti1p3_tool_config/dynamic_registration.py create mode 100644 src/pylti1p3/dynamic_registration.py diff --git a/src/pylti1p3/contrib/django/lti1p3_tool_config/dynamic_registration.py b/src/pylti1p3/contrib/django/lti1p3_tool_config/dynamic_registration.py new file mode 100644 index 0000000..a29d182 --- /dev/null +++ b/src/pylti1p3/contrib/django/lti1p3_tool_config/dynamic_registration.py @@ -0,0 +1,112 @@ +from typing import Any, Dict + +from django.urls import reverse_lazy +from django.templatetags.static import static + +from pylti1p3.dynamic_registration import DynamicRegistration, generate_key_pair + +from .models import LtiTool, LtiToolKey + + +class DjangoDynamicRegistration(DynamicRegistration): + + initiate_login_url = reverse_lazy("lti:login") + + jwks_url = reverse_lazy("lti:jwks") + + launch_url = reverse_lazy("lti:launch") + + # The path of the tool's logo image, under ``STATIC_ROOT``. + logo_file = "lti/logo.png" + + def __init__(self, request): + super().__init__() + + self.request = request + + def get_issuer_keys(self, issuer_name: str): + key_obj, created = LtiToolKey.objects.get_or_create(name=issuer_name) + if created: + private_key, public_key = generate_key_pair() + key_obj.private_key = private_key + key_obj.public_key = public_key + key_obj.save() + return key_obj + + def get_initiate_login_uri(self) -> str: + return self.request.build_absolute_uri(str(self.initiate_login_url)) + + def get_jwks_uri(self) -> str: + return self.request.build_absolute_uri(str(self.jwks_url)) + + def get_redirect_uris(self) -> list[str]: + return [self.get_target_link_uri()] + + def get_domain(self) -> str: + return self.request.get_host() + + def get_target_link_uri(self) -> str: + return self.request.build_absolute_uri(self.launch_url) + + def get_logo_uri(self) -> str: + return self.request.build_absolute_uri(static(self.logo_file)) + + def get_openid_configuration_endpoint(self): + return self.request.GET.get("openid_configuration") + + def get_registration_token(self): + return self.request.GET.get("registration_token") + + def get_platform_name(self, openid_configuration: Dict[str, Any]) -> str: + """ + Get the name of the platform this tool is registering with. + """ + return openid_configuration.get( + "https://purl.imsglobal.org/spec/lti-platform-configuration", {} + ).get("product_family_code", "") + + def complete_registration( + self, openid_configuration: Dict[str, Any], openid_registration: Dict[str, Any] + ): + title = self.get_platform_name(openid_configuration) + + tool_key = self.get_issuer_keys(openid_configuration["issuer"]) + + tool_spec = "https://purl.imsglobal.org/spec/lti-tool-configuration" + deployment_id = openid_registration[tool_spec].get("deployment_id") + + deployment_ids = [] + + if deployment_id is not None: + deployment_ids.append(deployment_id) + + platform_config, _created = LtiTool.objects.update_or_create( + issuer=openid_configuration["issuer"], + client_id=openid_registration["client_id"], + defaults={ + "title": title, + "auth_login_url": openid_configuration["authorization_endpoint"], + "auth_token_url": openid_configuration["token_endpoint"], + "auth_audience": openid_configuration["token_endpoint"], + "key_set_url": openid_configuration["jwks_uri"], + "tool_key": tool_key, + "deployment_ids": deployment_ids, + }, + ) + + platform_config.save() # type: ignore + return platform_config + + def keys_for_issuer(self, issuer_name: str) -> LtiToolKey: + """ + Get the public and private keys for a given issuer. + + If they don't exist yet, then create them. + """ + key_obj, created = LtiToolKey.objects.get_or_create(name=issuer_name) + if created: + private_key, public_key = generate_key_pair() + key_obj.private_key = private_key + key_obj.public_key = public_key + key_obj.save() + return key_obj diff --git a/src/pylti1p3/dynamic_registration.py b/src/pylti1p3/dynamic_registration.py new file mode 100644 index 0000000..3fd0042 --- /dev/null +++ b/src/pylti1p3/dynamic_registration.py @@ -0,0 +1,328 @@ +""" +Dynamic registration flow. + +Written with reference to: + +* https://www.imsglobal.org/spec/lti-dr/v1p0 + - Learning Tools Interoperability (LTI) Dynamic Registration Specification +* https://gist.github.com/onemenzel/32d661649863a48efafce9e3fbbd6253 by Lukas Menzel / onamenzel +* https://moodlelti.theedtech.dev/dynreg/ - "LTI Advantage Automatic Registration" by Claude Vervoort +""" + +from typing import Any, Dict + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +import requests +from requests.exceptions import RequestException + +from .exception import LtiException + + +def generate_key_pair(key_size: int = 4096) -> tuple[str, str]: + """ + Generates an RSA key pair. + + :param key_size: key bits + + :returns: a dict with the keys "public" and "private", containing PEM-encoded RSA keys. \ + This is not returned as a tuple so that the user of this function never confuses them. + """ + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=key_size, + ) + public_key = private_key.public_key() + + private_key_str = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + public_key_str = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + return (private_key_str, public_key_str) + + +class DynamicRegistration: + """ + Controls the process of dynamic registration. + """ + + client_name = "" # The name of the tool. + description = "" # A short plain-text description of the tool. + + response_types = ["id_token"] + + grant_types = ["implicit", "client_credentials"] + + def get_client_name(self) -> str: + """ + Get the tool's name. + """ + return self.client_name + + def get_response_types(self) -> list[str]: + """ + Get the response types supported by the tool. + + Must include "id_token". + """ + + return self.response_types + + def get_grant_types(self): + """ + Get the grant types supported by the tool. + + Must include "implicit" and "client_credentials". + """ + + return self.grant_types + + def get_initiate_login_uri(self) -> str: + """ + Get the URI used by the platform to initiate the LTI launch. + + e.g. "https://example.com/login" + """ + raise NotImplementedError + + def get_jwks_uri(self) -> str: + """ + Get the URI to fetch the public JSON Web Key Set. + + e.g. "http://example.com/jwks" + """ + raise NotImplementedError + + def get_redirect_uris(self) -> list[str]: + """ + Get the tool's OIDC redirect URIs. + """ + raise NotImplementedError + + def get_scopes(self) -> list[str]: + """ + Get the list of scopes that the tool would like. + + Each entry should be a scope name following the naming conventions described at + https://www.imsglobal.org/spec/security/v1p0/#h_scope-naming-conventions. + + e.g.:: + + ['https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'] + """ + return [] + + def get_domain(self) -> str: + """ + Get the domain that the tool runs on, including the port if necessary. + + e.g. "example.com:8000" + """ + raise NotImplementedError + + def get_target_link_uri(self) -> str: + """ + The actual end-point that should be executed at the end of the OpenID Connect authentication flow. + + e.g. "https://example.com/" + """ + raise NotImplementedError + + def get_claims(self) -> list[str]: + """ + A list of claims indicating which information this tool desires to be included in each idtoken. + + See https://www.imsglobal.org/spec/lti/v1p3#user-identity-claims + + ``'sub'`` is the only required claim. + + e.g.:: + + ['iss', 'sub', 'name'] + """ + return [ + "sub", + ] + + def get_messages(self) -> list[Dict[str, str]]: + """ + A list of messages supported by this tool. + + Each should be a dict containing keys as described in the ``message`` table at + https://www.imsglobal.org/spec/lti-dr/v1p0#tool-configuration-0. + + e.g.:: + + [{ + 'type': 'LtiDeepLinkingRequest', + 'target_link_uri': 'https://example.com/launch', + 'label': 'New tool link', + }] + """ + return [] + + def get_description(self) -> str: + """ + Get a short plain-text description of the tool. + """ + return self.description + + def get_logo_uri(self) -> str: + """ + Get the URI of the tool's logo image. + + e.g. "https://example.com/logo.png" + """ + raise NotImplementedError + + def lti_registration_data(self) -> Dict[str, Any]: + """ + Get the registration data object to send back to the platform. + + Must return an object matching the specification described at + https://www.imsglobal.org/spec/lti-dr/v1p0#tool-configuration. + """ + + return { + "response_types": self.get_response_types(), + "application_type": "web", + "client_name": self.get_client_name(), + "initiate_login_uri": self.get_initiate_login_uri(), + "grant_types": self.get_grant_types(), + "jwks_uri": self.get_jwks_uri(), + "token_endpoint_auth_method": "private_key_jwt", + "redirect_uris": self.get_redirect_uris(), + "scope": " ".join(self.get_scopes()), + "https://purl.imsglobal.org/spec/lti-tool-configuration": { + "domain": self.get_domain(), # get_host includes the port. + "target_link_uri": self.get_target_link_uri(), + "claims": self.get_claims(), + "messages": self.get_messages(), + "description": self.get_description(), + }, + "logo_uri": self.get_logo_uri(), + } + + def get_openid_configuration_endpoint(self) -> str: + """ + Get the URI of the public OpenID configuration endpoint. + + This is the ``openid_configuration`` parameter passed in the initial GET request from the platform. + """ + raise NotImplementedError + + def get_registration_token(self) -> str: + """ + Get the platform's registration token. + + This is the ``registration_token`` parameter passed in the initial GET request from the platform. + """ + raise NotImplementedError + + def get_openid_configuration(self) -> Dict[str, Any]: + openid_configuration_endpoint = self.get_openid_configuration_endpoint() + + with requests.Session() as session: + resp = session.get(openid_configuration_endpoint) + try: + openid_configuration = resp.json() + except RequestException as e: + raise LtiException( + f"The OpenID configuration data is invalid: {e}" + ) from e + + return openid_configuration + + def register(self) -> Dict[str, Any]: + """ + Perform the tool registration. + + Returns the OpenID registration response as described. + """ + + openid_configuration_endpoint = self.get_openid_configuration_endpoint() + registration_token = self.get_registration_token() + + if not openid_configuration_endpoint: + raise LtiException("No OpenID configuration endpoint was specified.") + + openid_configuration = self.get_openid_configuration() + + with requests.Session() as session: + + assert ( + "registration_endpoint" in openid_configuration + ), "The OpenID config does not have a registration endpoint." + + tool_provider_registration_endpoint = openid_configuration[ + "registration_endpoint" + ] + + registration_data = self.lti_registration_data() + + headers = {"Accept": "application/json"} + + if registration_token is not None: + headers["Authorization"] = "Bearer " + registration_token + + response = session.post( + tool_provider_registration_endpoint, + headers=headers, + json=registration_data, + ) + + openid_registration = response.json() + + conf_spec = "https://purl.imsglobal.org/spec/lti-platform-configuration" + assert ( + conf_spec in openid_configuration + ), "The OpenID config is not an LTI platform configuration" + + tool_spec = "https://purl.imsglobal.org/spec/lti-tool-configuration" + assert ( + tool_spec in openid_registration + ), "The OpenID registration is not an LTI tool configuration" + + tool = self.complete_registration(openid_configuration, openid_registration) + return tool + + def complete_registration( + self, openid_configuration: Dict[str, Any], openid_registration: Dict[str, Any] + ) -> Any: + """ + Save the registration information. + + :param openid_configuration: the public configuration data returned by the platform. + + :param openid_registration: the object returned by the platform after registration, as described by + https://www.imsglobal.org/spec/lti-dr/v1p0#tool-configuration-from-the-platform. + + :returns: an object representing the tool representation. + """ + + raise NotImplementedError + + def complete_html(self): + """ + HTML for the final step of the process: it should make a JavaScript postMessage call to the platform, + telling it that the registration process is complete. + """ + + return """ + + + + +

The registration is now complete. You can close this window and return to the registered platform.

+ + + """ # noqa: E501